/** * AI Software Factory — Integration: OAuth2 (TASK-0129). * * Proves the authorization-code + PKCE flow IN-PROCESS against the real * stack (Testcontainers Postgres/Redis), driving the endpoints the way a * third-party app would — with the user's Bearer access token standing in * for the login cookie on the authorize step (the Playwright E2E covers the * browser cookie path end-to-end): * * 1. app registration: owner creates a confidential app (clientSecret * returned ONCE; list never shows it) + a public app (no secret, PKCE * mandatory). Non-owner (MEMBER) is rejected. * 2. authorize: anonymous → 302 /login?next=…; invalid redirect_uri * (exact match) → 400; invalid scope → 400 INVALID_SCOPE; public * client without PKCE → 400; logged-in user → 200 consent HTML (app * name + scope descriptions). * 3. token: authorization_code grant issues access + refresh (access JWT * claims: sub, client_id, scope, type:"oauth", jti); the code is * SINGLE-USE (second use → 400 INVALID_GRANT); wrong code_verifier → * 400; confidential client with bad secret → 400 INVALID_CLIENT. * 4. scope enforcement (the docs §8.2 contract): a pages:read token * lists pages 200 but POST → 403 `api_key_scope_write` with * `details[0].scope === "pages:write"`; a pages:write token can * create; a token pointed at a NON-MEMBER workspace → 401. * 5. refresh rotation (§1.2): rotating issues a NEW token + invalidates * the old; replaying the old one → 400 INVALID_GRANT + the whole family * is killed (the new one also fails) + audit `oauth.token_revoked * {reason:"reuse_detected"}` — and the user's LOGIN session is * untouched (collateral-revoke check). * 6. revoke (RFC 7009): refresh → the family dies; access (jti) → the * token 401s immediately; unknown tokens → 200 (no existence leak). * 7. clients: GET /oauth/clients/:id → metadata (200 / 404 unknown). * 8. audit: code_issued / token_issued / token_revoked all recorded. */ import { createHash, randomBytes } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; let owner: Awaited>; let member: Awaited>; let wsA: { workspaceId: string; slug: string }; /** PKCE S256 (mirrors the server's pkceChallenge — RFC 7636). */ const s256 = (verifier: string): string => createHash("sha256").update(verifier, "ascii").digest("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const verifier = () => randomBytes(32).toString("base64url"); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** Decode an unverified JWT's payload (claims are not a secret here). */ const jwtClaims = (token: string): Record => JSON.parse(Buffer.from(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); async function createOAuthApp(opts: { name?: string; scopes?: string[]; public?: boolean; redirectUri?: string; }): Promise<{ clientId: string; clientSecret?: string; redirectUri: string }> { const res = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/oauth/apps`, { headers: authHeaders(owner.accessToken!), body: { name: opts.name ?? `App ${Date.now()}`, scopes: opts.scopes ?? ["pages:read"], redirectUris: [opts.redirectUri ?? `http://localhost:9999/${Math.floor(Math.random() * 1e9)}.cb`], ...(opts.public !== undefined ? { public: opts.public } : {}), }, }); const raw = await res.text(); expect(res.status, raw).toBe(201); const body = JSON.parse(raw) as Record; return { clientId: String(body.clientId), clientSecret: body.clientSecret ? String(body.clientSecret) : undefined, redirectUri: opts.redirectUri ?? `http://localhost:9999/${Math.floor(Math.random() * 1e9)}.cb` }; } /** The full authorize → approve step; returns the code (+state). */ async function authorizeCode( userId_token: string, regApp: { clientId: string; clientSecret?: string; redirectUri: string }, opts: { scopes?: string[]; verifier?: string; state?: string } ): Promise<{ code: string; state?: string }> { const params = new URLSearchParams({ response_type: "code", client_id: regApp.clientId, redirect_uri: regApp.redirectUri, scope: (opts.scopes ?? ["pages:read"]).join(" "), ...(opts.state ? { state: opts.state } : {}), ...(opts.verifier ? { code_challenge: s256(opts.verifier), code_challenge_method: "S256" } : {}) }); const consent = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(userId_token), redirect: "manual" }); const html = await consent.text(); const rej = html.match(/([^<]*)<\/strong>\.?\s*([^<]*)/); expect(consent.status, `rejected=${rej ? rej[1] + ": " + rej[2] : "none"}`).toBe(200); expect(html).toContain("This application wants to access your account"); // The consent screen carries a server-signed CSRF token (hidden field) // bound to this user + this exact authorize request — echo it back. const consentToken = html.match(/name="consent_token" value="([^"]+)"/)?.[1]; expect(consentToken, "consent page must carry the consent_token hidden field").toBeTruthy(); // Approve: the form POST re-validates server-side (user re-resolved from // the Bearer, never trusted from the form). const form = new URLSearchParams({ client_id: regApp.clientId, redirect_uri: regApp.redirectUri, scope: (opts.scopes ?? ["pages:read"]).join(" "), decision: "allow", consent_token: consentToken!, ...(opts.state ? { state: opts.state } : {}), ...(opts.verifier ? { code_challenge: s256(opts.verifier), code_challenge_method: "S256" } : {}) }); // Raw fetch: the app.request helper JSON.stringifies the body, which would // quote the urlencoded form and break the server-side re-validation. const approve = await fetch(`${app.baseUrl}/api/v1/oauth/authorize`, { method: "POST", headers: { ...authHeaders(userId_token), "Content-Type": "application/x-www-form-urlencoded" }, body: form.toString(), redirect: "manual" }); expect(approve.status, await approve.text()).toBe(302); const location = new URL(approve.headers.get("location")!); const code = location.searchParams.get("code"); expect(code).toMatch(/^oc_/); return { code: code!, state: location.searchParams.get("state") ?? undefined }; } /** The token endpoint (JSON; the canonical error envelope carries the RFC code). */ async function tokenEndpoint(body: Record, auth?: string) { return app.request("POST", "/api/v1/oauth/token", { headers: { ...(auth ? { Authorization: `Basic ${Buffer.from(auth).toString("base64")}` } : {}) }, body }); } beforeAll(async () => { app = await createTestApp(); owner = await createUser(app.request, { name: "OAuth Owner" }); member = await createUser(app.request, { name: "OAuth Member" }); wsA = await createWorkspace(app.request, authHeaders(owner.accessToken!), "OAuth WS"); await addMemberViaSeam(app.request, app.internalServiceToken, wsA.workspaceId, member.email, "MEMBER"); // A page to read (created by the owner as a user, before any OAuth token). const page = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: authHeaders(owner.accessToken!), body: { title: "OAuth fixture page" } }); expect(page.status, await page.text()).toBe(201); }, 180_000); afterAll(async () => { await app?.close(); }); describe("app registration (workspace-scoped, Integration provider=oauth)", () => { it("owner creates a confidential app — secret returned ONCE, never listed", async () => { const app1 = await createOAuthApp({ name: "Secret App", scopes: ["pages:read", "pages:write"], redirectUri: "http://localhost:9999/secret.cb" }); expect(app1.clientId).toMatch(/^cli_/); expect(app1.clientSecret).toMatch(/^cli_sec_/); const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/oauth/apps`, { headers: authHeaders(owner.accessToken!) }); expect(list.status).toBe(200); const body = await json(list); const row = (body.data as Array>).find((r) => r.clientId === app1.clientId); expect(row).toBeTruthy(); expect(JSON.stringify(row)).not.toContain(app1.clientSecret!); // hashed at rest expect(row!.public).toBe(false); }); it("owner creates a public app — no secret, PKCE will be mandatory", async () => { const app1 = await createOAuthApp({ name: "Public App", scopes: ["pages:read"], public: true, redirectUri: "http://localhost:9999/public.cb" }); expect(app1.clientSecret).toBeUndefined(); const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/oauth/apps`, { headers: authHeaders(owner.accessToken!) }); const body = await json(list); const row = (body.data as Array>).find((r) => r.clientId === app1.clientId); expect(row!.public).toBe(true); }); it("a MEMBER (not owner/admin) cannot register apps", async () => { const res = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/oauth/apps`, { headers: authHeaders(member.accessToken!), body: { name: "Nope", scopes: ["pages:read"], redirectUris: ["http://localhost:9999/nope.cb"] } }); expect(res.status).toBe(403); }); }); describe("authorize", () => { it("anonymous → 302 /login?next=", async () => { const app1 = await createOAuthApp({ name: "Login App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/login.cb" }); const params = new URLSearchParams({ response_type: "code", client_id: app1.clientId, redirect_uri: "http://localhost:9999/login.cb", scope: "pages:read" }); // No auth header at all (anonymous): a plain fetch with no Authorization. const anon = await fetch(`${app.baseUrl}/api/v1/oauth/authorize?${params.toString()}`, { redirect: "manual" }); expect(anon.status).toBe(302); const location = anon.headers.get("location")!; expect(location).toContain("/login?next="); expect(decodeURIComponent(location)).toContain("/oauth/authorize?"); }); it("redirect_uri must EXACTLY match the allow-list (open-redirect guard)", async () => { const app1 = await createOAuthApp({ name: "Redirect App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/exact.cb" }); const params = new URLSearchParams({ response_type: "code", client_id: app1.clientId, redirect_uri: "http://localhost:9999/other.cb", scope: "pages:read" }); const res = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(owner.accessToken!) }); expect(res.status).toBe(400); const html = await res.text(); expect(html).toContain("invalid_request"); }); it("a scope NOT offered by the client → 400 invalid_scope", async () => { const app1 = await createOAuthApp({ name: "Scope App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/scope.cb" }); const params = new URLSearchParams({ response_type: "code", client_id: app1.clientId, redirect_uri: "http://localhost:9999/scope.cb", scope: "databases:write" }); const res = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(owner.accessToken!) }); expect(res.status).toBe(400); expect(await res.text()).toContain("invalid_scope"); }); it("a PUBLIC client without PKCE is rejected", async () => { const app1 = await createOAuthApp({ name: "Pkce App", scopes: ["pages:read"], public: true, redirectUri: "http://localhost:9999/pkce.cb" }); const params = new URLSearchParams({ response_type: "code", client_id: app1.clientId, redirect_uri: "http://localhost:9999/pkce.cb", scope: "pages:read" }); const res = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(owner.accessToken!) }); expect(res.status).toBe(400); expect(await res.text()).toContain("PKCE"); }); it("a logged-in user sees the consent screen (app name + scope descriptions)", async () => { const app1 = await createOAuthApp({ name: "Consent App", scopes: ["pages:read", "databases:read"], redirectUri: "http://localhost:9999/consent.cb" }); const params = new URLSearchParams({ response_type: "code", client_id: app1.clientId, redirect_uri: "http://localhost:9999/consent.cb", scope: "pages:read databases:read" }); const res = await app.request("GET", `/api/v1/oauth/authorize?${params.toString()}`, { headers: authHeaders(owner.accessToken!) }); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain("Consent App"); expect(html).toContain("View pages"); expect(html).toContain("View databases and records"); }); }); describe("token endpoint", () => { it("authorization_code grant issues access+refresh with the §8.2 claims", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "Grant App", scopes: ["pages:read", "databases:read"], redirectUri: "http://localhost:9999/grant.cb" }); const { code, state } = await authorizeCode(owner.accessToken!, app1, { scopes: ["pages:read", "databases:read"], verifier: v, state: "st-123" }); expect(state).toBe("st-123"); const res = await tokenEndpoint( { grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/grant.cb", client_id: app1.clientId, client_secret: app1.clientSecret } ); const raw = await res.text(); expect(res.status, raw).toBe(200); const body = JSON.parse(raw) as Record; expect(body.token_type).toBe("Bearer"); expect(body.expires_in).toBe(900); expect(body.scope).toBe("pages:read databases:read"); const claims = jwtClaims(String(body.access_token)); expect(claims.type).toBe("oauth"); expect(claims.client_id).toBe(app1.clientId); expect(claims.scope).toBe("pages:read databases:read"); expect(claims.jti).toBeTruthy(); expect(body.refresh_token).toMatch(/^rft_/); }); it("the code is SINGLE-USE — a second use fails", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "Once App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/once.cb" }); const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v }); const first = await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/once.cb", client_id: app1.clientId, client_secret: app1.clientSecret }); expect(first.status).toBe(200); const second = await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/once.cb", client_id: app1.clientId, client_secret: app1.clientSecret }); expect(second.status).toBe(400); expect((await json(second)).error?.code).toBe("INVALID_GRANT"); }); it("a wrong code_verifier → 400 INVALID_GRANT", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "Verifier App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/verifier.cb" }); const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v }); const res = await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: verifier(), // wrong redirect_uri: "http://localhost:9999/verifier.cb", client_id: app1.clientId, client_secret: app1.clientSecret }); expect(res.status).toBe(400); expect((await json(res)).error?.code).toBe("INVALID_GRANT"); }); it("a confidential client with a BAD secret → 400 INVALID_CLIENT", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "SecretCheck App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/secretcheck.cb" }); const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v }); const res = await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/secretcheck.cb", client_id: app1.clientId, client_secret: "cli_sec_wrong" }); expect(res.status).toBe(400); expect((await json(res)).error?.code).toBe("INVALID_CLIENT"); }); }); describe("scope enforcement (docs §8.2)", () => { it("a pages:read token reads 200 but creates 403 api_key_scope_write {scope: pages:write}", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "ReadOnly App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/ro.cb" }); const { code } = await authorizeCode(owner.accessToken!, app1, { scopes: ["pages:read"], verifier: v }); const tok = await json( await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/ro.cb", client_id: app1.clientId, client_secret: app1.clientSecret }) ); const auth = { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsA.workspaceId }; const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: auth }); expect(list.status, await list.text()).toBe(200); const create = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: auth, body: { title: "denied" } }); expect(create.status).toBe(403); const err = (await json(create)).error as { code: string; details?: Array<{ reason?: string; scope?: string }> }; expect(err.code).toBe("FORBIDDEN"); expect(err.details?.[0]?.reason).toBe("api_key_scope_write"); expect(err.details?.[0]?.scope).toBe("pages:write"); }); it("a pages:write token can create pages", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "Write App", scopes: ["pages:write"], redirectUri: "http://localhost:9999/w.cb" }); const { code } = await authorizeCode(owner.accessToken!, app1, { scopes: ["pages:write"], verifier: v }); const tok = await json( await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/w.cb", client_id: app1.clientId, client_secret: app1.clientSecret }) ); const create = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsA.workspaceId }, body: { title: "OAuth created page" } }); expect(create.status, await create.text()).toBe(201); }); it("a token pointed at a NON-MEMBER workspace → 401", async () => { // A second workspace the owner is in but the MEMBER is not — the member // obtains a token (the member is a member of wsA, so consent works) and // then tries wsB via X-Factory-Workspace. const v = verifier(); const app1 = await createOAuthApp({ name: "CrossWs App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/cross.cb" }); const { code } = await authorizeCode(member.accessToken!, app1, { scopes: ["pages:read"], verifier: v }); const tok = await json( await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/cross.cb", client_id: app1.clientId, client_secret: app1.clientSecret }) ); const wsB = await createWorkspace(app.request, authHeaders(owner.accessToken!), "OAuth WS B"); const res = await app.request("GET", `/api/v1/workspaces/${wsB.workspaceId}/pages`, { headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsB.workspaceId } }); expect(res.status).toBe(401); }); }); describe("refresh rotation + family reuse-kill (§1.2)", () => { it("rotates (new refresh, old dead); replaying the OLD one kills the family — without touching the login session", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "Rotate App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/rotate.cb" }); const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v }); const first = await json( await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/rotate.cb", client_id: app1.clientId, client_secret: app1.clientSecret }) ); // Rotate once — legit. const rotated = await tokenEndpoint({ grant_type: "refresh_token", refresh_token: first.refresh_token, client_id: app1.clientId, client_secret: app1.clientSecret }); const raw = await rotated.text(); expect(rotated.status, raw).toBe(200); const second = JSON.parse(raw) as Record; expect(second.refresh_token).not.toBe(first.refresh_token); // Replay the OLD (already-rotated) token → reuse attack → family kill. const reuse = await tokenEndpoint({ grant_type: "refresh_token", refresh_token: first.refresh_token, client_id: app1.clientId, client_secret: app1.clientSecret }); expect(reuse.status).toBe(400); expect((await json(reuse)).error?.code).toBe("INVALID_GRANT"); // The NEW token is now dead too (whole family killed). const afterKill = await tokenEndpoint({ grant_type: "refresh_token", refresh_token: second.refresh_token, client_id: app1.clientId, client_secret: app1.clientSecret }); expect(afterKill.status).toBe(400); // …but the user's LOGIN session (the DB refresh-token family) is intact — // no collateral revoke. const stillIn = await app.request("GET", "/api/v1/workspaces", { headers: authHeaders(owner.accessToken!) }); expect(stillIn.status).toBe(200); // And it is audited. const killed = await countAuditEvents(app.prisma, { action: "oauth.token_revoked" }); expect(killed).toBeGreaterThan(0); }); }); describe("revoke (RFC 7009)", () => { it("revoking a refresh token kills the family; unknown tokens → 200 (no leak)", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "Revoke App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/revoke.cb" }); const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v }); const tok = await json( await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/revoke.cb", client_id: app1.clientId, client_secret: app1.clientSecret }) ); const rev = await app.request("POST", "/api/v1/oauth/revoke", { body: { token: tok.refresh_token, token_type_hint: "refresh_token" } }); expect(rev.status).toBe(200); const dead = await tokenEndpoint({ grant_type: "refresh_token", refresh_token: tok.refresh_token, client_id: app1.clientId, client_secret: app1.clientSecret }); expect(dead.status).toBe(400); const unknown = await app.request("POST", "/api/v1/oauth/revoke", { body: { token: "rft_doesnotexist" } }); expect(unknown.status).toBe(200); }); it("revoking an ACCESS token (jti) rejects it immediately", async () => { const v = verifier(); const app1 = await createOAuthApp({ name: "RevokeAcc App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/revokeacc.cb" }); const { code } = await authorizeCode(owner.accessToken!, app1, { verifier: v }); const tok = await json( await tokenEndpoint({ grant_type: "authorization_code", code, code_verifier: v, redirect_uri: "http://localhost:9999/revokeacc.cb", client_id: app1.clientId, client_secret: app1.clientSecret }) ); const works = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsA.workspaceId } }); expect(works.status).toBe(200); const rev = await app.request("POST", "/api/v1/oauth/revoke", { body: { token: tok.access_token, token_type_hint: "access_token" } }); expect(rev.status).toBe(200); const dead = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: { Authorization: `Bearer ${tok.access_token}`, "X-Factory-Workspace": wsA.workspaceId } }); expect(dead.status).toBe(401); }); }); describe("clients metadata + audit", () => { it("GET /oauth/clients/:id → metadata; unknown → 404", async () => { const app1 = await createOAuthApp({ name: "Meta App", scopes: ["pages:read"], redirectUri: "http://localhost:9999/meta.cb" }); const ok = await app.request("GET", `/api/v1/oauth/clients/${app1.clientId}`); expect(ok.status).toBe(200); const body = await json(ok); expect(body.name).toBe("Meta App"); expect(body.scopes).toContain("pages:read"); const miss = await app.request("GET", "/api/v1/oauth/clients/cli_doesnotexist"); expect(miss.status).toBe(404); }); it("the three audit events were recorded", async () => { expect(await countAuditEvents(app.prisma, { action: "oauth.code_issued" })).toBeGreaterThan(0); expect(await countAuditEvents(app.prisma, { action: "oauth.token_issued" })).toBeGreaterThan(0); expect(await countAuditEvents(app.prisma, { action: "oauth.token_revoked" })).toBeGreaterThan(0); }); });