import { Body, Controller, Delete, Get, HttpCode, Inject, Param, Patch, Post, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; import { REFRESH_COOKIE } from "../../constants.js"; import { ApiError } from "../../common/errors.js"; import { OAUTH_SCOPE_DESCRIPTIONS, serializeScopeList, type OAuthScope } from "./scopes.js"; import { OauthService, type AuthorizeDecision } from "./oauth.service.js"; /** The consent decision narrowed to the shape the consent page needs. */ interface ConsentData { app: { name: string; logoUrl?: string; clientId: string }; requestedScopes?: string[]; redirectUri?: string; state?: string; codeChallenge?: string; codeChallengeMethod?: string; /** Signed consent CSRF token (echoed in a hidden field; verified on the * form POST — see `verifyConsentToken`). */ consentToken?: string; } function isConsent(d: AuthorizeDecision): d is AuthorizeDecision & ConsentData & { type: "consent" } { return d.type === "consent" && !!d.app && !!d.redirectUri && !!d.userId; } /** * TASK-0129 — the OAuth2 endpoints (docs/api/authentication.md §8). * * All `/oauth/*` paths are PUBLIC (whitelisted in AuthGuard) — the * authorization-code endpoints authenticate with the login cookie, the * PKCE verifier, and the client secret, NOT a Bearer token. The token * endpoint is the credential-exchange boundary. * * The consent screen is served by the API as a self-contained HTML page * (no external assets, no client JS required — a plain form POST) so the * flow works pre-login (redirect to /login?next=…) and the HTML surface * has zero XSS vector (all values are server strings or escaped). */ function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } @Controller("oauth") export class OauthController { constructor(@Inject(OauthService) private readonly oauth: OauthService) {} @Get("authorize") async authorize(@Query() query: Record, @Req() req: Request, @Res() res: Response) { const params = this.queryParams(query); const decision = await this.oauth.authorize(params, this.cookie(req), this.bearer(req), this.origin(req)); if (decision.type === "login" && decision.nextUrl) { res.redirect(302, decision.nextUrl); return; } if (decision.type === "error") { res.status(decision.status ?? 400).type("html").send(this.errorPage(decision.error ?? "invalid_request", decision.description ?? "")); return; } if (!isConsent(decision)) { res.status(400).type("html").send(this.errorPage("invalid_request", "The authorization request is no longer valid.")); return; } res.type("html").send(this.consentPage(decision)); } /** * Consent decision (form POST). The server RE-VALIDATES every authorize * parameter (never trusting the form) and re-resolves the user from the * cookie — the form's user identity is ignored. */ @Post("authorize") async decide(@Body() body: Record, @Req() req: Request, @Res() res: Response) { const params = this.bodyParams(body); const decision = await this.oauth.authorize(params, this.cookie(req), this.bearer(req), this.origin(req)); if (decision.type === "login" && decision.nextUrl) { res.redirect(302, decision.nextUrl); return; } if (decision.type !== "consent" || !decision.userId || !decision.app || !decision.redirectUri) { res.status(400).type("html").send(this.errorPage("invalid_request", "The authorization session is no longer valid. Start the flow again.")); return; } // CSRF: the signed consent token the form echoed back must bind this // (re-resolved) user to the SAME re-validated request — without it the // decision is rejected. (A cross-origin page cannot obtain the nonce: // the consent screen is served only to an authenticated session.) if ( !this.oauth.verifyConsentToken(body.consent_token, decision.userId, { clientId: decision.app.clientId, redirectUri: decision.redirectUri, scopes: serializeScopeList(decision.requestedScopes ?? []), codeChallenge: decision.codeChallenge ?? null, codeChallengeMethod: decision.codeChallengeMethod ?? null }) ) { res.status(400).type("html").send(this.errorPage("invalid_request", "The authorization session is no longer valid. Start the flow again.")); return; } const decisionValue = body.decision === "allow" ? "allow" : "deny"; const location = decisionValue === "allow" ? await this.oauth.approveCode( { clientId: decision.app.clientId, userId: decision.userId, scopes: decision.requestedScopes ?? [], redirectUri: decision.redirectUri, state: decision.state, codeChallenge: decision.codeChallenge, codeChallengeMethod: decision.codeChallengeMethod }, req ) : await this.oauth.denyRedirect(decision.redirectUri, decision.state); res.redirect(302, location); } /** * The token endpoint — RFC 6749 + PKCE (JSON in/out). Success returns the * token set. Errors throw `ApiError` (INVALID_GRANT / INVALID_CLIENT / * UNAUTHORIZED) and the GLOBAL exception filter shapes them into the * canonical envelope with the documented §8.2 `code` — no hand-rolled * catch, no stack, no secrets leak (C-13.7). */ @Post("token") @HttpCode(200) async token(@Body() body: Record, @Req() req: Request) { return this.oauth.token(req, body); } /** RFC 7009 revocation — always 200, never reveals token existence. */ @Post("revoke") async revoke(@Body() body: Record, @Req() req: Request, @Res() res: Response) { await this.oauth.revoke(typeof body.token === "string" ? body.token : undefined, typeof body.token_type_hint === "string" ? body.token_type_hint : undefined, req); res.status(200).json({}); } /** Public client metadata (consent screen + third parties). */ @Get("clients/:clientId") async client(@Param("clientId") clientId: string, @Res() res: Response) { const meta = await this.oauth.clientMetadata(clientId); if (!meta) { res.status(404).json({ error: "invalid_client", error_description: "Unknown client." }); return; } res.json(meta); } // ---- helpers ---- private queryParams(query: Record): Record { return { response_type: query.response_type, client_id: query.client_id, redirect_uri: query.redirect_uri, scope: query.scope, state: query.state, code_challenge: query.code_challenge, code_challenge_method: query.code_challenge_method }; } private bodyParams(body: Record): Record { return { response_type: "code", client_id: body.client_id, redirect_uri: body.redirect_uri, scope: body.scope, state: body.state, code_challenge: body.code_challenge, code_challenge_method: body.code_challenge_method }; } private cookie(req: Request): string | undefined { const header = req.headers.cookie; if (!header) return undefined; for (const part of header.split(";")) { const idx = part.indexOf("="); if (idx === -1) continue; if (part.slice(0, idx).trim() === REFRESH_COOKIE) { try { return decodeURIComponent(part.slice(idx + 1).trim()); } catch { return undefined; } } } return undefined; } /** The API origin (scheme://host[:port]) for building absolute resume URLs. */ private origin(req: Request): string { return `${req.protocol}://${req.get("host")}`; } private bearer(req: Request): string | undefined { const header = req.headers.authorization; if (header && header.startsWith("Bearer ")) { const t = header.slice(7).trim(); return t && !t.startsWith("fk_") ? t : undefined; } return undefined; } private errorPage(error: string, description: string): string { return `Authorization error

Authorization error

The request was rejected: ${escapeHtml(error)}. ${escapeHtml(description)}

Go to Factory
`; } private consentPage(decision: ConsentData): string { const app = decision.app; const logo = app.logoUrl ? `` : ""; const scopeRows = (decision.requestedScopes ?? []) .map((s) => { const label = OAUTH_SCOPE_DESCRIPTIONS[s as OAuthScope] ?? s; return ``; }) .join(""); const hidden = (name: string, value: string | undefined) => (value !== undefined ? `` : ""); return `Authorize ${escapeHtml(app.name)}

This application wants to access your account

${logo}

${escapeHtml(app.name)}

It is requesting the following permissions:

${scopeRows || '

No permissions requested.

'}
${hidden("client_id", decision.app!.clientId)}${hidden("redirect_uri", decision.redirectUri)}${hidden("scope", (decision.requestedScopes ?? []).join(" "))}${hidden("state", decision.state)}${hidden("code_challenge", decision.codeChallenge)}${hidden("code_challenge_method", decision.codeChallengeMethod)}${hidden("consent_token", decision.consentToken)}
`; } } /** * TASK-0129 — OAuth app registration (workspace-scoped). * * Auth: a user JWT (Owner/Admin of the workspace) OR an ADMIN-scoped API * key (assertRole's api branch rejects READ/WRITE keys with the scope * reason). `POST` returns the `clientSecret` ONCE (reveal-once, same * pattern as API keys). */ @Controller("workspaces/:workspaceId/oauth/apps") export class OauthAppsController { constructor(@Inject(OauthService) private readonly oauth: OauthService) {} /** * The USER id to attribute the app row to (createdById). For API keys the * guard resolves the key's CREATOR into `auth.userId`; scope decisions * never consult this (assertRole's api branch uses the key itself). */ private identity(req: Request): string { const auth = req.auth; if (!auth) throw ApiError.unauthorized("Authentication required (user JWT or API key).", [{ reason: "token_missing" }]); if (auth.kind === "api" && !auth.apiKeyId) throw ApiError.unauthorized("Authentication required.", [{ reason: "token_malformed" }]); return auth.userId ?? "system"; } @Post() async create(@Param("workspaceId") workspaceId: string, @Body() body: { name?: string; logoUrl?: string; redirectUris?: string[]; scopes?: string[]; public?: boolean }, @Req() req: Request) { if (!body.name || typeof body.name !== "string" || !body.name.trim()) throw ApiError.validation("name is required.", [{ field: "name", issue: "required" }]); if (!Array.isArray(body.redirectUris) || body.redirectUris.length === 0) throw ApiError.validation("redirectUris must be a non-empty array.", [{ field: "redirectUris", issue: "required" }]); if (!Array.isArray(body.scopes)) throw ApiError.validation("scopes must be an array.", [{ field: "scopes", issue: "required" }]); return this.oauth.createApp(workspaceId, this.identity(req), { name: body.name.trim(), logoUrl: body.logoUrl, redirectUris: body.redirectUris, scopes: body.scopes, public: body.public }, req); } @Get() async list(@Param("workspaceId") workspaceId: string, @Req() req: Request) { return this.oauth.listApps(workspaceId, this.identity(req)); } @Patch(":clientId") async update(@Param("workspaceId") workspaceId: string, @Param("clientId") clientId: string, @Body() body: { name?: string; logoUrl?: string | null; redirectUris?: string[]; scopes?: string[] }, @Req() req: Request) { return this.oauth.updateApp(workspaceId, this.identity(req), clientId, body, req); } @Delete(":clientId") async delete(@Param("workspaceId") workspaceId: string, @Param("clientId") clientId: string, @Req() req: Request) { return this.oauth.deleteApp(workspaceId, this.identity(req), clientId, req); } }