1
35import { createHash, randomBytes } from "node:crypto";
36import { afterAll, beforeAll, describe, expect, it } from "vitest";
37import { createTestApp, type TestAppHandle } from "./helpers/api";
38import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed";
39
40let app: TestAppHandle;
41let owner: Awaited<ReturnType<typeof createUser>>;
42let member: Awaited<ReturnType<typeof createUser>>;
43let wsA: { workspaceId: string; slug: string };
44
45
46const s256 = (verifier: string): string =>
47 createHash("sha256").update(verifier, "ascii").digest("base64").replace(/\+/g, "-").replace(/\
48const verifier = () => randomBytes(32).toString("base64url");
49
50const json = async (res: Response): Promise<Record<string, unknown>> =>
51 (await res.json().catch(() => ({}))) as Record<string, unknown>;
52
53
54const jwtClaims = (token: string): Record<string, unknown> =>
55 JSON.parse(Buffer.from(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
56
57async function createOAuthApp(opts: {
58 name?: string;
59 scopes?: string[];
60 public?: boolean;
61 redirectUri?: string;
62}): Promise<{ clientId: string; clientSecret?: string; redirectUri: string }> {
63 const res = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/oauth/apps`, {
64 headers: authHeaders(owner.accessToken!),
65 body: {
66 name: opts.name ?? `App ${Date.now()}`,
67 scopes: opts.scopes ?? ["pages:read"],
68 redirectUris: [opts.redirectUri ?? `http://localhost:9999/${Math.floor(Math.random() * 1e9)}.cb`],
69 ...(opts.public !== undefined ? { public: opts.public } : {}),
70 },
71 });
72 const raw = await res.text();
73 expect(res.status, raw).toBe(201);
74 const body = JSON.parse(raw) as Record<string, unknown>;
75 return {
76 clientId: String(body.clientId),
77 clientSecret: body.clientSecret ? String(body.clientSecret) : undefined,
78 redirectUri: opts.redirectUri ?? `http://localhost:9999/${Math.floor(Math.random() * 1e9)}.cb`
79 };
80}
81
82
83async function authorizeCode(
84 userId_token: string,
85 regApp: { clientId: string; clientSecret?: string; redirectUri: string },
86 opts: { scopes?: string[]; verifier?: string; state?: string }
87): Promise<{ code: string; state?: string }> {
88 const params = new URLSearchParams({
89 response_type: "code",
90 client_id: regApp.clientId,
91 redirect_uri: regApp.redirectUri,
92 scope: (opts.scopes ?? ["pages:read"]).join(" "),
93 ...(opts.state ? { state: opts.state } : {}),
94 ...(opts.verifier ? { code_challenge: s256(opts.verifier), code_challenge_method: "S256" } : {})
95 });
96 const consent = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, {
97 headers: authHeaders(userId_token),
98 redirect: "manual"
99 });
100 const html = await consent.text();
101 const rej = html.match(/<strong>([^<]*)<\/strong>\.?\s*([^<]*)/);
102 expect(consent.status, `rejected=${rej ? rej[1] + ": " + rej[2] : "none"}`).toBe(200);
103 expect(html).toContain("This application wants to access your account");
104
105
106 const consentToken = html.match(/name="consent_token" value="([^"]+)"/)?.[1];
107 expect(consentToken, "consent page must carry the consent_token hidden field").toBeTruthy();
108
109
110 const form = new URLSearchParams({
111 client_id: regApp.clientId,
112 redirect_uri: regApp.redirectUri,
113 scope: (opts.scopes ?? ["pages:read"]).join(" "),
114 decision: "allow",
115 consent_token: consentToken!,
116 ...(opts.state ? { state: opts.state } : {}),
117 ...(opts.verifier ? { code_challenge: s256(opts.verifier), code_challenge_method: "S256" } : {})
118 });
119
120
121 const approve = await fetch(`${app.baseUrl}/api/v1/oauth/authorize`, {
122 method: "POST",
123 headers: { ...authHeaders(userId_token), "Content-Type": "application/x-www-form-urlencoded" },
124 body: form.toString(),
125 redirect: "manual"
126 });
127 expect(approve.status, await approve.text()).toBe(302);
128 const location = new URL(approve.headers.get("location")!);
129 const code = location.searchParams.get("code");
130 expect(code).toMatch(/^oc_/);
131 return { code: code!, state: location.searchParams.get("state") ?? undefined };
132}
133
134
135async function tokenEndpoint(body: Record<string, unknown>, auth?: string) {
136 return app.request("POST", "/api/v1/oauth/token", {
137 headers: { ...(auth ? { Authorization: `Basic ${Buffer.from(auth).toString("base64")}` } : {}) },
138 body
139 });
140}
141
142beforeAll(async () => {
143 app = await createTestApp();
144 owner = await createUser(app.request, { name: "OAuth Owner" });
145 member = await createUser(app.request, { name: "OAuth Member" });
146 wsA = await createWorkspace(app.request, authHeaders(owner.accessToken!), "OAuth WS");
147 await addMemberViaSeam(app.request, app.internalServiceToken, wsA.workspaceId, member.email, "MEMBER");
148
149 const page = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
150 headers: authHeaders(owner.accessToken!),
151 body: { title: "OAuth fixture page" }
152 });
153 expect(page.status, await page.text()).toBe(201);
154}, 180_000);
155
156afterAll(async () => {
157 await app?.close();
158});
159
160describe("app registration (workspace-scoped, Integration provider=oauth)", () => {
161 it("owner creates a confidential app — secret returned ONCE, never listed", async () => {
162 const app1 = await createOAuthApp({ name: "Secret App", scopes: ["pages:read", "pages:write"], redirectUri: "http://localhost:9999/secret.cb" });
163 expect(app1.clientId).toMatch(/^cli_/);
164 expect(app1.clientSecret).toMatch(/^cli_sec_/);
165 const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/oauth/apps`, {
166 headers: authHeaders(owner.accessToken!)
167 });
168 expect(list.status).toBe(200);
169 const body = await json(list);
170 const row = (body.data as Array<Record<string, unknown>>).find((r) => r.clientId === app1.clientId);
171 expect(row).toBeTruthy();
172 expect(JSON.stringify(row)).not.toContain(app1.clientSecret!);
173 expect(row!.public).toBe(false);
174 });
175
176 it("owner creates a public app — no secret, PKCE will be mandatory", async () => {
177 const app1 = await createOAuthApp({ name: "Public App", scopes: ["pages:read"], public: true, redirectUri: "http://localhost:9999/public.cb" });
178 expect(app1.clientSecret).toBeUndefined();
179 const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/oauth/apps`, {
180 headers: authHeaders(owner.accessToken!)
181 });
182 const body = await json(list);
183 const row = (body.data as Array<Record<string, unknown>>).find((r) => r.clientId === app1.clientId);
184 expect(row!.public).toBe(true);
185 });
186
187 it("a MEMBER (not owner/admin) cannot register apps", async () => {
188 const res = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/oauth/apps`, {
189 headers: authHeaders(member.accessToken!),
190 body: { name: "Nope", scopes: ["pages:read"], redirectUris: ["http://localhost:9999/nope.cb"] }
191 });
192 expect(res.status).toBe(403);
193 });
194});
195
196describe("authorize", () => {
197 it("anonymous → 302 /login?next=<encoded authorize url>", async () => {
198 const app1 = await createOAuthApp({ name: "Login App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/login.cb" });
199 const params = new URLSearchParams({
200 response_type: "code",
201 client_id: app1.clientId,
202 redirect_uri: "http://localhost:9999/login.cb",
203 scope: "pages:read"
204 });
205
206 const anon = await fetch(`${app.baseUrl}/api/v1/oauth/authorize?${params.toString()}`, { redirect: "manual" });
207 expect(anon.status).toBe(302);
208 const location = anon.headers.get("location")!;
209 expect(location).toContain("/login?next=");
210 expect(decodeURIComponent(location)).toContain("/oauth/authorize?");
211 });
212
213 it("redirect_uri must EXACTLY match the allow-list (open-redirect guard)", async () => {
214 const app1 = await createOAuthApp({ name: "Redirect App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/exact.cb" });
215 const params = new URLSearchParams({
216 response_type: "code",
217 client_id: app1.clientId,
218 redirect_uri: "http://localhost:9999/other.cb",
219 scope: "pages:read"
220 });
221 const res = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(owner.accessToken!) });
222 expect(res.status).toBe(400);
223 const html = await res.text();
224 expect(html).toContain("invalid_request");
225 });
226
227 it("a scope NOT offered by the client → 400 invalid_scope", async () => {
228 const app1 = await createOAuthApp({ name: "Scope App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/scope.cb" });
229 const params = new URLSearchParams({
230 response_type: "code",
231 client_id: app1.clientId,
232 redirect_uri: "http://localhost:9999/scope.cb",
233 scope: "databases:write"
234 });
235 const res = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(owner.accessToken!) });
236 expect(res.status).toBe(400);
237 expect(await res.text()).toContain("invalid_scope");
238 });
239
240 it("a PUBLIC client without PKCE is rejected", async () => {
241 const app1 = await createOAuthApp({ name: "Pkce App", scopes: ["pages:read"], public: true, redirectUri: "http://localhost:9999/pkce.cb" });
242 const params = new URLSearchParams({
243 response_type: "code",
244 client_id: app1.clientId,
245 redirect_uri: "http://localhost:9999/pkce.cb",
246 scope: "pages:read"
247 });
248 const res = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(owner.accessToken!) });
249 expect(res.status).toBe(400);
250 expect(await res.text()).toContain("PKCE");
251 });
252
253 it("a logged-in user sees the consent screen (app name + scope descriptions)", async () => {
254 const app1 = await createOAuthApp({ name: "Consent App", scopes: ["pages:read", "databases:read"], redirectUri: "http://localhost:9999/consent.cb" });
255 const params = new URLSearchParams({
256 response_type: "code",
257 client_id: app1.clientId,
258 redirect_uri: "http://localhost:9999/consent.cb",
259 scope: "pages:read databases:read"
260 });
261 const res = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(owner.accessToken!) });
262 expect(res.status).toBe(200);
263 const html = await res.text();
264 expect(html).toContain("Consent App");
265 expect(html).toContain("View pages");
266 expect(html).toContain("View databases and records");
267 });
268});
269
270describe("token endpoint", () => {
271 it("authorization_code grant issues access+refresh with the §8.2 claims", async () => {
272 const v = verifier();
273 const app1 = await createOAuthApp({ name: "Grant App", scopes: ["pages:read", "databases:read"], redirectUri: "http://localhost:9999/grant.cb" });
274 const { code, state } = await authorizeCode(owner.accessToken!, app1, { scopes: ["pages:read", "databases:read"], verifier: v, state: "st-123" });
275 expect(state).toBe("st-123");
276 const res = await tokenEndpoint(
277 {
278 grant_type: "authorization_code",
279 code,
280 code_verifier: v,
281 redirect_uri: "http://localhost:9999/grant.cb",
282 client_id: app1.clientId,
283 client_secret: app1.clientSecret
284 }
285 );
286 const raw = await res.text();
287 expect(res.status, raw).toBe(200);
288 const body = JSON.parse(raw) as Record<string, unknown>;
289 expect(body.token_type).toBe("Bearer");
290 expect(body.expires_in).toBe(900);
291 expect(body.scope).toBe("pages:read databases:read");
292 const claims = jwtClaims(String(body.access_token));
293 expect(claims.type).toBe("oauth");
294 expect(claims.client_id).toBe(app1.clientId);
295 expect(claims.scope).toBe("pages:read databases:read");
296 expect(claims.jti).toBeTruthy();
297 expect(body.refresh_token).toMatch(/^rft_/);
298 });
299
300 it("the code is SINGLE-USE — a second use fails", async () => {
301 const v = verifier();
302 const app1 = await createOAuthApp({ name: "Once App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/once.cb" });
303 const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v });
304 const first = await tokenEndpoint({
305 grant_type: "authorization_code",
306 code,
307 code_verifier: v,
308 redirect_uri: "http://localhost:9999/once.cb",
309 client_id: app1.clientId,
310 client_secret: app1.clientSecret
311 });
312 expect(first.status).toBe(200);
313 const second = await tokenEndpoint({
314 grant_type: "authorization_code",
315 code,
316 code_verifier: v,
317 redirect_uri: "http://localhost:9999/once.cb",
318 client_id: app1.clientId,
319 client_secret: app1.clientSecret
320 });
321 expect(second.status).toBe(400);
322 expect((await json(second)).error?.code).toBe("INVALID_GRANT");
323 });
324
325 it("a wrong code_verifier → 400 INVALID_GRANT", async () => {
326 const v = verifier();
327 const app1 = await createOAuthApp({ name: "Verifier App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/verifier.cb" });
328 const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v });
329 const res = await tokenEndpoint({
330 grant_type: "authorization_code",
331 code,
332 code_verifier: verifier(),
333 redirect_uri: "http://localhost:9999/verifier.cb",
334 client_id: app1.clientId,
335 client_secret: app1.clientSecret
336 });
337 expect(res.status).toBe(400);
338 expect((await json(res)).error?.code).toBe("INVALID_GRANT");
339 });
340
341 it("a confidential client with a BAD secret → 400 INVALID_CLIENT", async () => {
342 const v = verifier();
343 const app1 = await createOAuthApp({ name: "SecretCheck App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/secretcheck.cb" });
344 const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v });
345 const res = await tokenEndpoint({
346 grant_type: "authorization_code",
347 code,
348 code_verifier: v,
349 redirect_uri: "http://localhost:9999/secretcheck.cb",
350 client_id: app1.clientId,
351 client_secret: "cli_sec_wrong"
352 });
353 expect(res.status).toBe(400);
354 expect((await json(res)).error?.code).toBe("INVALID_CLIENT");
355 });
356});
357
358describe("scope enforcement (docs §8.2)", () => {
359 it("a pages:read token reads 200 but creates 403 api_key_scope_write {scope: pages:write}", async () => {
360 const v = verifier();
361 const app1 = await createOAuthApp({ name: "ReadOnly App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/ro.cb" });
362 const { code } = await authorizeCode(owner.accessToken!, app1, { scopes: ["pages:read"], verifier: v });
363 const tok = await json(
364 await tokenEndpoint({
365 grant_type: "authorization_code",
366 code,
367 code_verifier: v,
368 redirect_uri: "http://localhost:9999/ro.cb",
369 client_id: app1.clientId,
370 client_secret: app1.clientSecret
371 })
372 );
373 const auth = { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsA.workspaceId };
374 const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: auth });
375 expect(list.status, await list.text()).toBe(200);
376 const create = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
377 headers: auth,
378 body: { title: "denied" }
379 });
380 expect(create.status).toBe(403);
381 const err = (await json(create)).error as { code: string; details?: Array<{ reason?: string; scope?: string }> };
382 expect(err.code).toBe("FORBIDDEN");
383 expect(err.details?.[0]?.reason).toBe("api_key_scope_write");
384 expect(err.details?.[0]?.scope).toBe("pages:write");
385 });
386
387 it("a pages:write token can create pages", async () => {
388 const v = verifier();
389 const app1 = await createOAuthApp({ name: "Write App", scopes: ["pages:write"], redirectUri: "http://localhost:9999/w.cb" });
390 const { code } = await authorizeCode(owner.accessToken!, app1, { scopes: ["pages:write"], verifier: v });
391 const tok = await json(
392 await tokenEndpoint({
393 grant_type: "authorization_code",
394 code,
395 code_verifier: v,
396 redirect_uri: "http://localhost:9999/w.cb",
397 client_id: app1.clientId,
398 client_secret: app1.clientSecret
399 })
400 );
401 const create = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
402 headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsA.workspaceId },
403 body: { title: "OAuth created page" }
404 });
405 expect(create.status, await create.text()).toBe(201);
406 });
407
408 it("a token pointed at a NON-MEMBER workspace → 401", async () => {
409
410
411
412 const v = verifier();
413 const app1 = await createOAuthApp({ name: "CrossWs App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/cross.cb" });
414 const { code } = await authorizeCode(member.accessToken!, app1, { scopes: ["pages:read"], verifier: v });
415 const tok = await json(
416 await tokenEndpoint({
417 grant_type: "authorization_code",
418 code,
419 code_verifier: v,
420 redirect_uri: "http://localhost:9999/cross.cb",
421 client_id: app1.clientId,
422 client_secret: app1.clientSecret
423 })
424 );
425 const wsB = await createWorkspace(app.request, authHeaders(owner.accessToken!), "OAuth WS B");
426 const res = await app.request("GET", `/api/v1/workspaces/${wsB.workspaceId}/pages`, {
427 headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsB.workspaceId }
428 });
429 expect(res.status).toBe(401);
430 });
431});
432
433describe("refresh rotation + family reuse-kill (§1.2)", () => {
434 it("rotates (new refresh, old dead); replaying the OLD one kills the family — without touching the login session", async () => {
435 const v = verifier();
436 const app1 = await createOAuthApp({ name: "Rotate App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/rotate.cb" });
437 const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v });
438 const first = await json(
439 await tokenEndpoint({
440 grant_type: "authorization_code",
441 code,
442 code_verifier: v,
443 redirect_uri: "http://localhost:9999/rotate.cb",
444 client_id: app1.clientId,
445 client_secret: app1.clientSecret
446 })
447 );
448
449 const rotated = await tokenEndpoint({
450 grant_type: "refresh_token",
451 refresh_token: first.refresh_token,
452 client_id: app1.clientId,
453 client_secret: app1.clientSecret
454 });
455 const raw = await rotated.text();
456 expect(rotated.status, raw).toBe(200);
457 const second = JSON.parse(raw) as Record<string, unknown>;
458 expect(second.refresh_token).not.toBe(first.refresh_token);
459
460 const reuse = await tokenEndpoint({
461 grant_type: "refresh_token",
462 refresh_token: first.refresh_token,
463 client_id: app1.clientId,
464 client_secret: app1.clientSecret
465 });
466 expect(reuse.status).toBe(400);
467 expect((await json(reuse)).error?.code).toBe("INVALID_GRANT");
468
469 const afterKill = await tokenEndpoint({
470 grant_type: "refresh_token",
471 refresh_token: second.refresh_token,
472 client_id: app1.clientId,
473 client_secret: app1.clientSecret
474 });
475 expect(afterKill.status).toBe(400);
476
477
478 const stillIn = await app.request("GET", "/api/v1/workspaces", { headers: authHeaders(owner.accessToken!) });
479 expect(stillIn.status).toBe(200);
480
481 const killed = await countAuditEvents(app.prisma, { action: "oauth.token_revoked" });
482 expect(killed).toBeGreaterThan(0);
483 });
484});
485
486describe("revoke (RFC 7009)", () => {
487 it("revoking a refresh token kills the family; unknown tokens → 200 (no leak)", async () => {
488 const v = verifier();
489 const app1 = await createOAuthApp({ name: "Revoke App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/revoke.cb" });
490 const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v });
491 const tok = await json(
492 await tokenEndpoint({
493 grant_type: "authorization_code",
494 code,
495 code_verifier: v,
496 redirect_uri: "http://localhost:9999/revoke.cb",
497 client_id: app1.clientId,
498 client_secret: app1.clientSecret
499 })
500 );
501 const rev = await app.request("POST", "/api/v1/oauth/revoke", {
502 body: { token: tok.refresh_token, token_type_hint: "refresh_token" }
503 });
504 expect(rev.status).toBe(200);
505 const dead = await tokenEndpoint({
506 grant_type: "refresh_token",
507 refresh_token: tok.refresh_token,
508 client_id: app1.clientId,
509 client_secret: app1.clientSecret
510 });
511 expect(dead.status).toBe(400);
512 const unknown = await app.request("POST", "/api/v1/oauth/revoke", { body: { token: "rft_doesnotexist" } });
513 expect(unknown.status).toBe(200);
514 });
515
516 it("revoking an ACCESS token (jti) rejects it immediately", async () => {
517 const v = verifier();
518 const app1 = await createOAuthApp({ name: "RevokeAcc App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/revokeacc.cb" });
519 const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v });
520 const tok = await json(
521 await tokenEndpoint({
522 grant_type: "authorization_code",
523 code,
524 code_verifier: v,
525 redirect_uri: "http://localhost:9999/revokeacc.cb",
526 client_id: app1.clientId,
527 client_secret: app1.clientSecret
528 })
529 );
530 const works = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
531 headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsA.workspaceId }
532 });
533 expect(works.status).toBe(200);
534 const rev = await app.request("POST", "/api/v1/oauth/revoke", {
535 body: { token: tok.access_token, token_type_hint: "access_token" }
536 });
537 expect(rev.status).toBe(200);
538 const dead = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
539 headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsA.workspaceId }
540 });
541 expect(dead.status).toBe(401);
542 });
543});
544
545describe("clients metadata + audit", () => {
546 it("GET /oauth/clients/:id → metadata; unknown → 404", async () => {
547 const app1 = await createOAuthApp({ name: "Meta App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/meta.cb" });
548 const ok = await app.request("GET", `/api/v1/oauth/clients/${app1.clientId}`);
549 expect(ok.status).toBe(200);
550 const body = await json(ok);
551 expect(body.name).toBe("Meta App");
552 expect(body.scopes).toContain("pages:read");
553 const miss = await app.request("GET", "/api/v1/oauth/clients/cli_doesnotexist");
554 expect(miss.status).toBe(404);
555 });
556
557 it("the three audit events were recorded", async () => {
558 expect(await countAuditEvents(app.prisma, { action: "oauth.code_issued" })).toBeGreaterThan(0);
559 expect(await countAuditEvents(app.prisma, { action: "oauth.token_issued" })).toBeGreaterThan(0);
560 expect(await countAuditEvents(app.prisma, { action: "oauth.token_revoked" })).toBeGreaterThan(0);
561 });
562});
563