1import { Body, Controller, Delete, Get, HttpCode, Inject, Param, Patch, Post, Query, Req, Res } from "@nestjs/common";
2import type { Request, Response } from "express";
3import { REFRESH_COOKIE } from "../../constants.js";
4import { ApiError } from "../../common/errors.js";
5import { OAUTH_SCOPE_DESCRIPTIONS, serializeScopeList, type OAuthScope } from "./scopes.js";
6import { OauthService, type AuthorizeDecision } from "./oauth.service.js";
7
8
9interface ConsentData {
10 app: { name: string; logoUrl?: string; clientId: string };
11 requestedScopes?: string[];
12 redirectUri?: string;
13 state?: string;
14 codeChallenge?: string;
15 codeChallengeMethod?: string;
16
18 consentToken?: string;
19}
20
21function isConsent(d: AuthorizeDecision): d is AuthorizeDecision & ConsentData & { type: "consent" } {
22 return d.type === "consent" && !!d.app && !!d.redirectUri && !!d.userId;
23}
24
25
38function escapeHtml(value: string): string {
39 return value
40 .replace(/&/g, "&")
41 .replace(/</g, "<")
42 .replace(/>/g, ">")
43 .replace(/"/g, """)
44 .replace(/'/g, "'");
45}
46
47@Controller("oauth")
48export class OauthController {
49 constructor(@Inject(OauthService) private readonly oauth: OauthService) {}
50
51 @Get("authorize")
52 async authorize(@Query() query: Record<string, string>, @Req() req: Request, @Res() res: Response) {
53 const params = this.queryParams(query);
54 const decision = await this.oauth.authorize(params, this.cookie(req), this.bearer(req), this.origin(req));
55 if (decision.type === "login" && decision.nextUrl) {
56 res.redirect(302, decision.nextUrl);
57 return;
58 }
59 if (decision.type === "error") {
60 res.status(decision.status ?? 400).type("html").send(this.errorPage(decision.error ?? "invalid_request", decision.description ?? ""));
61 return;
62 }
63 if (!isConsent(decision)) {
64 res.status(400).type("html").send(this.errorPage("invalid_request", "The authorization request is no longer valid."));
65 return;
66 }
67 res.type("html").send(this.consentPage(decision));
68 }
69
70
75 @Post("authorize")
76 async decide(@Body() body: Record<string, string>, @Req() req: Request, @Res() res: Response) {
77 const params = this.bodyParams(body);
78 const decision = await this.oauth.authorize(params, this.cookie(req), this.bearer(req), this.origin(req));
79 if (decision.type === "login" && decision.nextUrl) {
80 res.redirect(302, decision.nextUrl);
81 return;
82 }
83 if (decision.type !== "consent" || !decision.userId || !decision.app || !decision.redirectUri) {
84 res.status(400).type("html").send(this.errorPage("invalid_request", "The authorization session is no longer valid. Start the flow again."));
85 return;
86 }
87
88
89
90
91 if (
92 !this.oauth.verifyConsentToken(body.consent_token, decision.userId, {
93 clientId: decision.app.clientId,
94 redirectUri: decision.redirectUri,
95 scopes: serializeScopeList(decision.requestedScopes ?? []),
96 codeChallenge: decision.codeChallenge ?? null,
97 codeChallengeMethod: decision.codeChallengeMethod ?? null
98 })
99 ) {
100 res.status(400).type("html").send(this.errorPage("invalid_request", "The authorization session is no longer valid. Start the flow again."));
101 return;
102 }
103 const decisionValue = body.decision === "allow" ? "allow" : "deny";
104 const location =
105 decisionValue === "allow"
106 ? await this.oauth.approveCode(
107 {
108 clientId: decision.app.clientId,
109 userId: decision.userId,
110 scopes: decision.requestedScopes ?? [],
111 redirectUri: decision.redirectUri,
112 state: decision.state,
113 codeChallenge: decision.codeChallenge,
114 codeChallengeMethod: decision.codeChallengeMethod
115 },
116 req
117 )
118 : await this.oauth.denyRedirect(decision.redirectUri, decision.state);
119 res.redirect(302, location);
120 }
121
122
129 @Post("token")
130 @HttpCode(200)
131 async token(@Body() body: Record<string, unknown>, @Req() req: Request) {
132 return this.oauth.token(req, body);
133 }
134
135
136 @Post("revoke")
137 async revoke(@Body() body: Record<string, string>, @Req() req: Request, @Res() res: Response) {
138 await this.oauth.revoke(typeof body.token === "string" ? body.token : undefined, typeof body.token_type_hint === "string" ? body.token_type_hint : undefined, req);
139 res.status(200).json({});
140 }
141
142
143 @Get("clients/:clientId")
144 async client(@Param("clientId") clientId: string, @Res() res: Response) {
145 const meta = await this.oauth.clientMetadata(clientId);
146 if (!meta) {
147 res.status(404).json({ error: "invalid_client", error_description: "Unknown client." });
148 return;
149 }
150 res.json(meta);
151 }
152
153
154
155 private queryParams(query: Record<string, string>): Record<string, string | undefined> {
156 return {
157 response_type: query.response_type,
158 client_id: query.client_id,
159 redirect_uri: query.redirect_uri,
160 scope: query.scope,
161 state: query.state,
162 code_challenge: query.code_challenge,
163 code_challenge_method: query.code_challenge_method
164 };
165 }
166
167 private bodyParams(body: Record<string, string>): Record<string, string | undefined> {
168 return {
169 response_type: "code",
170 client_id: body.client_id,
171 redirect_uri: body.redirect_uri,
172 scope: body.scope,
173 state: body.state,
174 code_challenge: body.code_challenge,
175 code_challenge_method: body.code_challenge_method
176 };
177 }
178
179 private cookie(req: Request): string | undefined {
180 const header = req.headers.cookie;
181 if (!header) return undefined;
182 for (const part of header.split(";")) {
183 const idx = part.indexOf("=");
184 if (idx === -1) continue;
185 if (part.slice(0, idx).trim() === REFRESH_COOKIE) {
186 try {
187 return decodeURIComponent(part.slice(idx + 1).trim());
188 } catch {
189 return undefined;
190 }
191 }
192 }
193 return undefined;
194 }
195
196
197 private origin(req: Request): string {
198 return `${req.protocol}://${req.get("host")}`;
199 }
200
201 private bearer(req: Request): string | undefined {
202 const header = req.headers.authorization;
203 if (header && header.startsWith("Bearer ")) {
204 const t = header.slice(7).trim();
205 return t && !t.startsWith("fk_") ? t : undefined;
206 }
207 return undefined;
208 }
209
210 private errorPage(error: string, description: string): string {
211 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorization error</title></head><body style="font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f7f8fa"><main style="max-width:420px;background:#fff;border-radius:12px;padding:32px;box-shadow:0 1px 4px rgba(0,0,0,.08)"><h1 style="font-size:18px;margin:0 0 8px">Authorization error</h1><p style="margin:0 0 16px;color:#475467">The request was rejected: <strong>${escapeHtml(error)}</strong>. ${escapeHtml(description)}</p><a href="/" style="color:#2563eb;font-size:14px">Go to Factory</a></main></body></html>`;
212 }
213
214 private consentPage(decision: ConsentData): string {
215 const app = decision.app;
216 const logo = app.logoUrl ? `<img src="${escapeHtml(app.logoUrl)}" alt="" style="width:48px;height:48px;border-radius:8px;object-fit:cover" onerror="this.remove()">` : "";
217 const scopeRows = (decision.requestedScopes ?? [])
218 .map((s) => {
219 const label = OAUTH_SCOPE_DESCRIPTIONS[s as OAuthScope] ?? s;
220 return `<label style="display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-bottom:1px solid #eef0f3;font-size:14px"><input type="checkbox" checked disabled style="margin-top:2px"><span><strong>${escapeHtml(s)}</strong><br><span style="color:#667085">${escapeHtml(label)}</span></span></label>`;
221 })
222 .join("");
223 const hidden = (name: string, value: string | undefined) => (value !== undefined ? `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">` : "");
224 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Authorize ${escapeHtml(app.name)}</title></head><body style="font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f7f8fa"><main style="max-width:440px;width:100%;background:#fff;border-radius:12px;padding:32px;box-shadow:0 1px 4px rgba(0,0,0,.08)"><h1 style="font-size:16px;margin:0 0 4px;color:#667085">This application wants to access your account</h1><div style="display:flex;align-items:center;gap:12px;margin:16px 0">${logo}<h2 style="font-size:20px;margin:0">${escapeHtml(app.name)}</h2></div><p style="color:#475467;font-size:14px;margin:0 0 8px">It is requesting the following permissions:</p><div style="margin:8px 0 16px">${scopeRows || '<p style="color:#667085;font-size:14px">No permissions requested.</p>'}</div><form method="post" action="/api/v1/oauth/authorize">${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)}<div style="display:flex;gap:12px;margin-top:8px"><button type="submit" name="decision" value="deny" style="flex:1;padding:10px 16px;border:1px solid #d0d5dd;background:#fff;border-radius:8px;font-size:14px;cursor:pointer">Deny</button><button type="submit" name="decision" value="allow" style="flex:1;padding:10px 16px;border:none;background:#2563eb;color:#fff;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer">Allow</button></div></form></main></body></html>`;
225 }
226}
227
228
236@Controller("workspaces/:workspaceId/oauth/apps")
237export class OauthAppsController {
238 constructor(@Inject(OauthService) private readonly oauth: OauthService) {}
239
240
245 private identity(req: Request): string {
246 const auth = req.auth;
247 if (!auth) throw ApiError.unauthorized("Authentication required (user JWT or API key).", [{ reason: "token_missing" }]);
248 if (auth.kind === "api" && !auth.apiKeyId) throw ApiError.unauthorized("Authentication required.", [{ reason: "token_malformed" }]);
249 return auth.userId ?? "system";
250 }
251
252 @Post()
253 async create(@Param("workspaceId") workspaceId: string, @Body() body: { name?: string; logoUrl?: string; redirectUris?: string[]; scopes?: string[]; public?: boolean }, @Req() req: Request) {
254 if (!body.name || typeof body.name !== "string" || !body.name.trim()) throw ApiError.validation("name is required.", [{ field: "name", issue: "required" }]);
255 if (!Array.isArray(body.redirectUris) || body.redirectUris.length === 0) throw ApiError.validation("redirectUris must be a non-empty array.", [{ field: "redirectUris", issue: "required" }]);
256 if (!Array.isArray(body.scopes)) throw ApiError.validation("scopes must be an array.", [{ field: "scopes", issue: "required" }]);
257 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);
258 }
259
260 @Get()
261 async list(@Param("workspaceId") workspaceId: string, @Req() req: Request) {
262 return this.oauth.listApps(workspaceId, this.identity(req));
263 }
264
265 @Patch(":clientId")
266 async update(@Param("workspaceId") workspaceId: string, @Param("clientId") clientId: string, @Body() body: { name?: string; logoUrl?: string | null; redirectUris?: string[]; scopes?: string[] }, @Req() req: Request) {
267 return this.oauth.updateApp(workspaceId, this.identity(req), clientId, body, req);
268 }
269
270 @Delete(":clientId")
271 async delete(@Param("workspaceId") workspaceId: string, @Param("clientId") clientId: string, @Req() req: Request) {
272 return this.oauth.deleteApp(workspaceId, this.identity(req), clientId, req);
273 }
274}
275