From 643fbb94bc1e4a0c5478176f7f73c40144e1af67 Mon Sep 17 00:00:00 2001 From: glass beadian Date: Wed, 14 Oct 2026 11:33:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20OIDC=20phase=202=20=E2=80=94=20session-?= =?UTF-8?q?bound=20refresh=20tokens,=20server-side=20logout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hub sessions gain an optional sid backed by auth_sessions (login/reissue write the row; POST /auth/logout revokes it; pre-phase2 tokens are refused at reissue so revocation applies from the next epoch bump). The OIDC provider issues rotating refresh tokens (oidc_refresh_tokens, SHA-256 at rest, family-wide revocation on reuse detection) that live and die with the consenting Hub session — an oCIS session can no longer outlive the sign-in that minted it, and Hub logout propagates at the next refresh instead of the next silent-authorize. Discovery advertises refresh_token. Migration 034; RLS via the audit_append definer pattern for the unauthenticated token-endpoint lookup. --- .../034_auth_sessions_oidc_refresh.sql | 57 ++++++ apps/api/src/auth/auth.controller.ts | 9 +- apps/api/src/auth/auth.service.ts | 43 ++++- apps/api/src/auth/reissue.test.ts | 29 ++- .../session-lifecycle.integration.test.ts | 109 +++++++++++ apps/api/src/auth/session.ts | 7 + apps/api/src/oidc/oidc.controller.ts | 2 +- apps/api/src/oidc/oidc.refresh-store.ts | 171 ++++++++++++++++++ apps/api/src/oidc/oidc.service.ts | 110 ++++++++++- apps/api/src/oidc/oidc.test.ts | 83 ++++++++- 10 files changed, 601 insertions(+), 19 deletions(-) create mode 100644 apps/api/migrations/034_auth_sessions_oidc_refresh.sql create mode 100644 apps/api/src/auth/session-lifecycle.integration.test.ts create mode 100644 apps/api/src/oidc/oidc.refresh-store.ts diff --git a/apps/api/migrations/034_auth_sessions_oidc_refresh.sql b/apps/api/migrations/034_auth_sessions_oidc_refresh.sql new file mode 100644 index 0000000..6ce675c --- /dev/null +++ b/apps/api/migrations/034_auth_sessions_oidc_refresh.sql @@ -0,0 +1,57 @@ +-- OIDC phase 2: revocable Hub sessions + session-bound refresh tokens +-- (docs/design/2026-10-13-files-oidc-sso.md security note: logout must +-- propagate past the Hub cookie; refresh tokens inherit the Hub session's +-- lifetime so an oCIS session can never outlive the sign-in that minted it). + +-- One row per issued Hub session. Written at login/reissue, revoked at +-- logout. Stateless request validation (RbacGuard) is unchanged: rows are +-- consulted only by /oidc refresh and by reissue's liveness check. +CREATE TABLE auth_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz +); + +CREATE POLICY tenant_isolation ON auth_sessions + USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); + +CREATE INDEX auth_sessions_ws_user ON auth_sessions (workspace_id, user_id); +CREATE INDEX auth_sessions_expiry ON auth_sessions (expires_at) WHERE revoked_at IS NULL; + +-- Rotation chains share family_id; presenting a consumed token revokes the +-- whole family (OAuth BCP reuse detection). Rows cascade away with their +-- session, so a logged-out session takes its tokens with it. +CREATE TABLE oidc_refresh_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, + sid uuid NOT NULL REFERENCES auth_sessions (id) ON DELETE CASCADE, + client_id text NOT NULL, + scope text NOT NULL, + family_id uuid NOT NULL, + token_hash text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + consumed_at timestamptz +); + +CREATE POLICY tenant_isolation ON oidc_refresh_tokens + USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); + +CREATE INDEX oidc_refresh_family ON oidc_refresh_tokens (family_id); +CREATE INDEX oidc_refresh_sid ON oidc_refresh_tokens (sid); + +-- POST /oidc/token is unauthenticated: the refresh lookup runs before any +-- tenant context exists, so it goes through a definer-rights function (the +-- audit_append pattern). +CREATE FUNCTION oidc_refresh_lookup(p_token_hash text) RETURNS TABLE ( + id uuid, workspace_id uuid, user_id uuid, sid uuid, + client_id text, scope text, family_id uuid, + created_at timestamptz, consumed_at timestamptz +) LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$ + SELECT id, workspace_id, user_id, sid, client_id, scope, family_id, created_at, consumed_at + FROM oidc_refresh_tokens + WHERE token_hash = p_token_hash +$$; diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 5e16035..5bfd6c2 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -38,10 +38,15 @@ export class AuthController { return this.auth.reissue(session); } - /** Stateless tokens: logout is client-side (drop the token). Revocation-by-epoch covers role changes. */ + /** Signed session present ⇒ its server-side row is revoked (phase 2). Legacy tokens: client-side only. */ @Post("logout") @HttpCode(200) - logout(): { ok: true } { + logout(@Req() request: Request): { ok: true } { + const authorization = request.headers.authorization; + const session = authorization !== undefined && authorization.startsWith("Bearer ") + ? verifySession(authorization.slice("Bearer ".length)) + : null; + void this.auth.logout(session); return { ok: true }; } } diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index fb75724..6446361 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { BadRequestException, ConflictException, @@ -92,9 +92,12 @@ function requirePasswordStrength(password: string): void { * Re-issue a session for `userId` from the CURRENT membership row and epoch. * Must run inside a tenant transaction for `workspaceId`; callers that just * mutated memberships use it so their own session survives the epoch bump. + * Every call creates an auth_sessions row; pass `reuseSid` to keep the same + * server-side session (reissue) — the row must then still be live, which is + * how a logged-out session fails its next reissue. */ export async function issueSessionInTenant( - client: PoolClient, workspaceId: string, userId: string, expiresAt?: number, + client: PoolClient, workspaceId: string, userId: string, expiresAt?: number, reuseSid?: string, ): Promise { const membership = await client.query( "SELECT role FROM memberships WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'", @@ -106,7 +109,23 @@ export async function issueSessionInTenant( const epoch = ws.rows[0]?.authz_epoch as string | number | undefined; if (epoch === undefined) throw new NotFoundException("workspace not found"); const remaining = expiresAt === undefined ? undefined : Math.max(0, expiresAt - Math.floor(Date.now() / 1000)); - return issueSession({ userId, workspaceId, role, authzEpoch: Number(epoch) }, remaining); + const sid = reuseSid ?? randomUUID(); + const lifetimeSeconds = remaining ?? 12 * 3600; + if (reuseSid !== undefined) { + const row = await client.query( + "SELECT revoked_at FROM auth_sessions WHERE id = $1 AND workspace_id = $2 AND user_id = $3 FOR UPDATE", + [reuseSid, workspaceId, userId], + ); + const session = row.rows[0] as { revoked_at: Date | null } | undefined; + if (session === undefined || session.revoked_at !== null) throw new UnauthorizedException("session is no longer active"); + await client.query("UPDATE auth_sessions SET expires_at = now() + $1::interval WHERE id = $2", [`${lifetimeSeconds} seconds`, reuseSid]); + } else { + await client.query( + "INSERT INTO auth_sessions (id, workspace_id, user_id, expires_at) VALUES ($1, $2, $3, now() + $4::interval)", + [sid, workspaceId, userId, `${lifetimeSeconds} seconds`], + ); + } + return issueSession({ userId, workspaceId, role, authzEpoch: Number(epoch), sid }, remaining); } @Injectable() @@ -215,13 +234,14 @@ export class AuthService { } async reissue(session: SessionPayload): Promise { + if (session.sid === undefined) throw new UnauthorizedException("session predates server-side sessions; sign in again"); let token: string; try { token = await withTenant(session.workspaceId, (client) => - issueSessionInTenant(client, session.workspaceId, session.userId, session.expiresAt), + issueSessionInTenant(client, session.workspaceId, session.userId, session.expiresAt, session.sid), ); } catch (error) { - if (error instanceof ForbiddenException || error instanceof NotFoundException) { + if (error instanceof ForbiddenException || error instanceof NotFoundException || error instanceof UnauthorizedException) { throw new UnauthorizedException("membership no longer active"); } throw error; @@ -229,6 +249,19 @@ export class AuthService { return { token, me: await this.meFor(session.workspaceId, session.userId, token) }; } + /** + * Server-side logout: revoke the session row when the token carries a sid + * (phase 2 tokens). Best-effort and backward compatible — tokens without a + * sid (pre-migration) keep today's client-side-only semantics, and any + * failure here never blocks the client from dropping the token. + */ + async logout(session: SessionPayload | null): Promise { + if (session?.sid === undefined) return; + await withTenant(session.workspaceId, (client) => + client.query("UPDATE auth_sessions SET revoked_at = now() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL", [session.sid, session.userId]), + ).catch(() => undefined); + } + private async meFor(workspaceId: string, userId: string, token: string | null, expiresAt?: number): Promise { return withTenant(workspaceId, async (client) => { const res = await client.query( diff --git a/apps/api/src/auth/reissue.test.ts b/apps/api/src/auth/reissue.test.ts index c35a707..14e94cc 100644 --- a/apps/api/src/auth/reissue.test.ts +++ b/apps/api/src/auth/reissue.test.ts @@ -5,15 +5,20 @@ import { issueSession, verifySession } from "./session.js"; const workspaceId = "10000000-0000-4000-8000-000000000001"; const userId = "20000000-0000-4000-8000-000000000002"; +const sid = "30000000-0000-4000-8000-000000000003"; let role = "Publisher"; let epoch = 1; let active = true; +let sessionLive = true; vi.mock("../db/client.js", () => ({ withTenant: async (_workspaceId: string, fn: (client: PoolClient) => Promise) => fn({ query: async (sql: string) => { if (sql.includes("SELECT role FROM memberships")) return { rows: active ? [{ role }] : [] }; if (sql.includes("SELECT authz_epoch FROM workspaces")) return { rows: [{ authz_epoch: String(epoch) }] }; + if (sql.includes("SELECT revoked_at FROM auth_sessions")) return { rows: sessionLive ? [{ revoked_at: null }] : [] }; + if (sql.includes("UPDATE auth_sessions")) return { rowCount: 1 }; + if (sql.includes("INSERT INTO auth_sessions")) return { rows: [] }; if (sql.includes("SELECT m.role, u.username")) return { rows: active ? [{ role, username: "user", email: "user@example.com", display_name: "User", name: "Studio", slug: "studio" }] : [] }; if (sql.includes("SELECT permission FROM role_permissions")) return { rows: [{ permission: "draft.view" }] }; throw new Error(`unexpected SQL: ${sql}`); @@ -25,21 +30,39 @@ vi.mock("../db/client.js", () => ({ describe("session reissue", () => { it("refreshes a stale epoch and live role without extending the session expiry", async () => { active = true; + sessionLive = true; role = "Publisher"; epoch = 1; - const old = verifySession(issueSession({ userId, workspaceId, role, authzEpoch: epoch })); + const old = verifySession(issueSession({ userId, workspaceId, role, authzEpoch: epoch, sid })); if (old === null) throw new Error("failed to issue test session"); role = "Approver"; epoch = 2; const result = await new AuthService().reissue(old); expect(result.me.role).toBe("Approver"); expect(result.me.permissions).toContain("draft.view"); - expect(verifySession(result.token)).toMatchObject({ userId, workspaceId, role: "Approver", authzEpoch: 2, expiresAt: old.expiresAt }); + expect(verifySession(result.token)).toMatchObject({ userId, workspaceId, role: "Approver", authzEpoch: 2, expiresAt: old.expiresAt, sid }); + }); + + it("refuses a pre-phase2 token (no sid): revocation must apply from the next reissue", async () => { + active = true; + const old = verifySession(issueSession({ userId, workspaceId, role, authzEpoch: epoch })); + if (old === null) throw new Error("failed to issue test session"); + expect(old.sid).toBeUndefined(); + await expect(new AuthService().reissue(old)).rejects.toMatchObject({ status: 401 }); + }); + + it("refuses a revoked server-side session", async () => { + active = true; + sessionLive = false; + const old = verifySession(issueSession({ userId, workspaceId, role, authzEpoch: epoch, sid })); + if (old === null) throw new Error("failed to issue test session"); + await expect(new AuthService().reissue(old)).rejects.toMatchObject({ status: 401 }); }); it("refuses an inactive membership", async () => { active = false; - const old = verifySession(issueSession({ userId, workspaceId, role: "Publisher", authzEpoch: 1 })); + sessionLive = true; + const old = verifySession(issueSession({ userId, workspaceId, role: "Publisher", authzEpoch: 1, sid })); if (old === null) throw new Error("failed to issue test session"); await expect(new AuthService().reissue(old)).rejects.toMatchObject({ status: 401 }); }); diff --git a/apps/api/src/auth/session-lifecycle.integration.test.ts b/apps/api/src/auth/session-lifecycle.integration.test.ts new file mode 100644 index 0000000..c0697d5 --- /dev/null +++ b/apps/api/src/auth/session-lifecycle.integration.test.ts @@ -0,0 +1,109 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Pool, type PoolClient } from "pg"; +import { UnauthorizedException } from "@nestjs/common"; +import { issueSession, verifySession } from "./session.js"; +import { AuthService, issueSessionInTenant } from "./auth.service.js"; +import { hashPassword, verifyPassword } from "./password.js"; + +/** + * Phase 2 propagation against live PostgreSQL: + * - login issues a session token carrying a sid with a live auth_sessions row + * - reissue keeps the sid and survives while the row is live + * - logout revokes the row; the next reissue fails + * - a pre-phase2 token (no sid) is refused at reissue (sign in again) + * + * Owns its workspace in the chatgov IT database. Skips clean. + */ +const appUrl = process.env.CHATGOV_IT_DATABASE_URL; +const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL; + +describe.skipIf(appUrl === undefined || ownerUrl === undefined)("session lifecycle + propagation (live DB)", () => { + let ownerPool: Pool; + let appPool: Pool; + const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`; + let ws: string; + let userId: string; + + async function inTenant(fn: (client: PoolClient) => Promise): Promise { + const client = await ownerPool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT set_config('app.workspace_id', $1, true)", [ws]); + const result = await fn(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + const status401 = async (promise: Promise): Promise => { + try { + await promise; + return false; + } catch (err) { + return err instanceof UnauthorizedException && err.getStatus() === 401; + } + }; + + beforeAll(async () => { + process.env.DATABASE_URL = appUrl; + process.env.SUPER_DATABASE_URL = ownerUrl; + const { pool } = await import("../db/client.js"); + ownerPool = new Pool({ connectionString: ownerUrl }); + appPool = pool; + const c = await ownerPool.connect(); + try { + ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", ["sesslife", `sesslife-${suffix}`])).rows[0].id as string; + userId = (await c.query( + "INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id", + [`sesslife:u:${suffix}`, `sesslife-${suffix}`, `sesslife-${suffix}@example.com`, "Sess Life"], + )).rows[0].id as string; + await c.query("INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, 'Owner', 'active')", [ws, userId]); + await c.query("INSERT INTO user_passwords (user_id, password_hash) VALUES ($1, $2)", [userId, await hashPassword("Prop!2026x")]); + } finally { + c.release(); + } + }, 60_000); + + afterAll(async () => { + if (ownerPool !== undefined) { + await ownerPool.query("DELETE FROM workspaces WHERE id = $1", [ws]); + await ownerPool.query("DELETE FROM users WHERE external_id LIKE $1", [`sesslife:%:${suffix}`]); + await ownerPool.end(); + } + await appPool?.end(); + }); + + it("login → live row; reissue keeps the sid; logout kills reissue", async () => { + const token = await inTenant((client) => issueSessionInTenant(client, ws, userId)); + const payload = verifySession(token); + expect(payload?.sid).toBeDefined(); + const rows = await inTenant(async (c) => + (await c.query("SELECT revoked_at FROM auth_sessions WHERE workspace_id = $1 AND user_id = $2", [ws, userId])).rows); + expect(rows).toHaveLength(1); + expect(rows[0]?.revoked_at).toBeNull(); + + const reissued = await inTenant((client) => issueSessionInTenant(client, ws, userId, payload!.expiresAt, payload!.sid)); + expect(verifySession(reissued)?.sid).toBe(payload!.sid); + + await inTenant((client) => client.query("UPDATE auth_sessions SET revoked_at = now() WHERE id = $1", [payload!.sid])); + expect(await status401(inTenant((client) => issueSessionInTenant(client, ws, userId, payload!.expiresAt, payload!.sid)))).toBe(true); + }); + + it("the service refuses a pre-phase2 token (no sid) at reissue", async () => { + const epochRow = await ownerPool.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [ws]); + const legacy = issueSession({ userId, workspaceId: ws, role: "Owner", authzEpoch: Number(epochRow.rows[0].authz_epoch) }); + const payload = verifySession(legacy); + expect(payload?.sid).toBeUndefined(); + expect(await status401(new AuthService().reissue(payload!))).toBe(true); + }); + + it("password hash roundtrip stays sane for the seeded account", async () => { + const row = await ownerPool.query("SELECT password_hash FROM user_passwords WHERE user_id = $1", [userId]); + expect(await verifyPassword("Prop!2026x", row.rows[0].password_hash)).toBe(true); + }); +}); diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index f6c5d33..dc8255c 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -14,6 +14,13 @@ export interface SessionPayload { authzEpoch: number; issuedAt: number; expiresAt: number; + /** + * Server-side session row (auth_sessions) minted at login. Optional for + * backward compatibility: tokens issued before migration 034 have none, + * and their logout stays client-side only. Present ⇒ logout revokes the + * row and OIDC refresh tokens die with it. + */ + sid?: string; } function secret(): string { diff --git a/apps/api/src/oidc/oidc.controller.ts b/apps/api/src/oidc/oidc.controller.ts index 0ad22ad..6149658 100644 --- a/apps/api/src/oidc/oidc.controller.ts +++ b/apps/api/src/oidc/oidc.controller.ts @@ -48,7 +48,7 @@ export class OidcController { @Header("Cache-Control", "no-store") consent(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<{ redirectTo: string }> { const session = requireSession(request); - return this.oidc.consent(session.workspaceId, session.userId, body); + return this.oidc.consent(session.workspaceId, session.userId, body, session.sid ?? null); } @Post("token") diff --git a/apps/api/src/oidc/oidc.refresh-store.ts b/apps/api/src/oidc/oidc.refresh-store.ts new file mode 100644 index 0000000..6a0d8d7 --- /dev/null +++ b/apps/api/src/oidc/oidc.refresh-store.ts @@ -0,0 +1,171 @@ +import { withSystem, withTenant } from "../db/client.js"; + +/** + * Storage for OIDC refresh tokens (phase 2). One rotation chain shares + * family_id; presenting a consumed token revokes the family. Tokens bind to + * an auth_sessions row (sid) so their effective lifetime is the Hub + * session's: logout (or expiry) invalidates every downstream token. + * + * PgRefreshStore is the production impl. The lookup runs on the + * unauthenticated token endpoint before any tenant context exists, so it + * goes through the definer-rights oidc_refresh_lookup() (the audit_append + * pattern); every other call runs inside withTenant. + */ +export interface RefreshTokenRecord { + id: string; + workspaceId: string; + userId: string; + sid: string; + clientId: string; + scope: string; + familyId: string; + tokenHash: string; + createdAt: Date; + consumedAt: Date | null; +} + +export interface RefreshIssueInput { + workspaceId: string; + userId: string; + sid: string; + clientId: string; + scope: string; + familyId: string; + tokenHash: string; +} + +export interface OidcRefreshStore { + issue(input: RefreshIssueInput): Promise; + lookupByHash(tokenHash: string): Promise; + consume(record: RefreshTokenRecord): Promise; + /** Reuse detection: delete the whole chain; returns rows removed. */ + revokeFamily(workspaceId: string, familyId: string): Promise; + /** The minting Hub session must still be live (not revoked, not expired). */ + sessionAlive(workspaceId: string, sid: string): Promise; + /** Drop rows whose session is gone (best-effort hygiene, same tenant). */ + sweepOrphans(workspaceId: string): Promise; +} + +interface Row { + id: string; + workspace_id: string; + user_id: string; + sid: string; + client_id: string; + scope: string; + family_id: string; + token_hash: string; + created_at: Date; + consumed_at: Date | null; +} + +function fromRow(row: Row): RefreshTokenRecord { + return { + id: row.id, + workspaceId: row.workspace_id, + userId: row.user_id, + sid: row.sid, + clientId: row.client_id, + scope: row.scope, + familyId: row.family_id, + tokenHash: row.token_hash, + createdAt: new Date(row.created_at), + consumedAt: row.consumed_at === null ? null : new Date(row.consumed_at), + }; +} + +export class PgRefreshStore implements OidcRefreshStore { + async issue(input: RefreshIssueInput): Promise { + await withTenant(input.workspaceId, (client) => + client.query( + `INSERT INTO oidc_refresh_tokens (workspace_id, user_id, sid, client_id, scope, family_id, token_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [input.workspaceId, input.userId, input.sid, input.clientId, input.scope, input.familyId, input.tokenHash], + ), + ); + } + + async lookupByHash(tokenHash: string): Promise { + const row = (await withSystem((client) => client.query("SELECT * FROM oidc_refresh_lookup($1)", [tokenHash]))).rows[0]; + return row === undefined ? null : fromRow(row); + } + + async consume(record: RefreshTokenRecord): Promise { + await withTenant(record.workspaceId, (client) => + client.query("UPDATE oidc_refresh_tokens SET consumed_at = now() WHERE id = $1 AND consumed_at IS NULL", [record.id]), + ); + } + + async revokeFamily(workspaceId: string, familyId: string): Promise { + return (await withTenant(workspaceId, (client) => + client.query("DELETE FROM oidc_refresh_tokens WHERE workspace_id = $1 AND family_id = $2", [workspaceId, familyId]), + )).rowCount ?? 0; + } + + async sessionAlive(workspaceId: string, sid: string): Promise { + const found = (await withTenant(workspaceId, (client) => + client.query( + "SELECT 1 FROM auth_sessions WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL AND expires_at > now()", + [sid, workspaceId], + ), + )).rows.length; + return found === 1; + } + + async sweepOrphans(workspaceId: string): Promise { + await withTenant(workspaceId, (client) => + client.query( + `DELETE FROM oidc_refresh_tokens t USING auth_sessions s + WHERE t.sid = s.id AND (s.revoked_at IS NOT NULL OR s.expires_at < now())`, + ), + ); + } +} + +/** In-memory impl for unit tests (same semantics, no Postgres). */ +export class MemoryRefreshStore implements OidcRefreshStore { + readonly rows = new Map(); + + async issue(input: RefreshIssueInput): Promise { + this.rows.set(input.tokenHash, { + id: input.tokenHash, + workspaceId: input.workspaceId, + userId: input.userId, + sid: input.sid, + clientId: input.clientId, + scope: input.scope, + familyId: input.familyId, + tokenHash: input.tokenHash, + createdAt: new Date(), + consumedAt: null, + }); + } + + async lookupByHash(tokenHash: string): Promise { + return this.rows.get(tokenHash) ?? null; + } + + async consume(record: RefreshTokenRecord): Promise { + for (const row of this.rows.values()) if (row.id === record.id) row.consumedAt = new Date(); + } + + async revokeFamily(workspaceId: string, familyId: string): Promise { + let removed = 0; + for (const [hash, row] of [...this.rows]) { + if (row.workspaceId === workspaceId && row.familyId === familyId) { + this.rows.delete(hash); + removed++; + } + } + return removed; + } + + async sessionAlive(workspaceId: string, sid: string): Promise { + return this.liveSessions.has(`${workspaceId}:${sid}`); + } + + async sweepOrphans(): Promise {} + + /** Test control: which (workspace, sid) pairs count as live sessions. */ + readonly liveSessions = new Set(); +} diff --git a/apps/api/src/oidc/oidc.service.ts b/apps/api/src/oidc/oidc.service.ts index 8412f59..fc1a598 100644 --- a/apps/api/src/oidc/oidc.service.ts +++ b/apps/api/src/oidc/oidc.service.ts @@ -30,6 +30,9 @@ import { type OidcConfig, } from "./oidc.config.js"; import { FileKeyStore, OidcKeyring, type PublicJwk } from "./oidc.keys.js"; +import { PgRefreshStore, type OidcRefreshStore } from "./oidc.refresh-store.js"; + +const REFRESH_GRANT = "refresh_token"; /** The Hub account an authorization code is issued for. */ export interface OidcUser { @@ -52,6 +55,8 @@ export interface OidcDependencies { now: () => number; /** Usernames that already exist inside oCIS and must never be claimed by a Hub user. */ reservedUsernames: () => ReadonlySet; + /** Refresh-token persistence (phase 2); the default hits Postgres. */ + refreshStore: OidcRefreshStore; } export const OIDC_DEPENDENCIES: unique symbol = Symbol("OIDC_DEPENDENCIES"); @@ -62,6 +67,8 @@ export interface TokenResponse { expires_in: number; id_token: string; scope: string; + /** Present on the first-party client: rotate on every use (phase 2). */ + refresh_token?: string; } export interface UserInfo { @@ -85,6 +92,9 @@ interface CodeRecord { scopes: string[]; nonce: string | null; user: OidcUser; + /** The consenting Hub session — minted refresh tokens die with it. */ + workspaceId: string; + sid: string | null; authTime: number; expiresAt: number; } @@ -166,6 +176,7 @@ export class OidcService implements OnModuleInit { users: deps?.users ?? new DatabaseUserDirectory(), now: deps?.now ?? (() => Date.now()), reservedUsernames: deps?.reservedUsernames ?? reservedFromEnv, + refreshStore: deps?.refreshStore ?? new PgRefreshStore(), }; } @@ -188,7 +199,7 @@ export class OidcService implements OnModuleInit { jwks_uri: `${issuer}/jwks`, response_types_supported: ["code"], response_modes_supported: ["query"], - grant_types_supported: ["authorization_code"], + grant_types_supported: ["authorization_code", REFRESH_GRANT], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], token_endpoint_auth_methods_supported: ["none"], @@ -222,7 +233,7 @@ export class OidcService implements OnModuleInit { } /** POST /oidc/authorize/consent — the session's user approves (auto, first-party) and gets a code. */ - async consent(workspaceId: string, userId: string, body: unknown): Promise<{ redirectTo: string }> { + async consent(workspaceId: string, userId: string, body: unknown, sid: string | null = null): Promise<{ redirectTo: string }> { const config = this.config(); const result = validateAuthorize(authorizeParamsFrom(body)); if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`); @@ -253,6 +264,8 @@ export class OidcService implements OnModuleInit { scopes: request.scopes, nonce: request.nonce, user, + workspaceId, + sid, authTime: Math.floor(now / 1000), expiresAt: now + CODE_TTL_SECONDS * 1000, }); @@ -263,11 +276,13 @@ export class OidcService implements OnModuleInit { return { redirectTo: redirect.toString() }; } - /** POST /oidc/token — authorization_code + PKCE verifier → id_token + access_token. */ + /** POST /oidc/token — authorization_code+PKCE, or refresh_token rotation. */ async token(body: unknown): Promise { - const config = this.config(); const params = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record) : {}; - if (str(params, "grant_type") !== "authorization_code") throw new OAuthError("unsupported_grant_type", "only authorization_code is supported"); + const grantType = str(params, "grant_type"); + if (grantType === REFRESH_GRANT) return this.refreshGrant(params); + if (grantType !== "authorization_code") throw new OAuthError("unsupported_grant_type", "only authorization_code and refresh_token are supported"); + const config = this.config(); const clientId = str(params, "client_id"); if (clientId !== WEB_CLIENT_ID) throw new OAuthError("invalid_client", "unknown client_id", HttpStatus.UNAUTHORIZED); const code = str(params, "code"); @@ -311,7 +326,90 @@ export class OidcService implements OnModuleInit { at_hash: atHash(accessToken), ...claims, }); - return { access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, id_token: idToken, scope }; + const response: TokenResponse = { access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, id_token: idToken, scope }; + if (record.sid !== null) { + response.refresh_token = await this.mintRefreshToken({ ...record, sid: record.sid }); + } + return response; + } + + /** + * Phase 2: a rotation chain lives and dies with the consenting Hub + * session. A code minted by a pre-phase2 session token (no sid) gets no + * refresh token — an unbindable token must not be refreshable. + */ + private async mintRefreshToken(record: CodeRecord & { sid: string }): Promise { + const token = randomBytes(32).toString("base64url"); + await this.deps.refreshStore.sweepOrphans(record.workspaceId); + await this.deps.refreshStore.issue({ + workspaceId: record.workspaceId, + userId: record.user.id, + sid: record.sid, + clientId: record.clientId, + scope: record.scopes.join(" "), + familyId: randomUUID(), + tokenHash: sha256(token).toString("base64url"), + }); + return token; + } + + /** + * grant_type=refresh_token: rotate-on-use with reuse detection. Presenting + * a consumed token revokes its whole chain (OAuth BCP) — that is the + * signal that a token escaped. Every accept re-checks the minting Hub + * session (logout/expiry propagates here) and active membership. + */ + private async refreshGrant(params: Record): Promise { + const config = this.config(); + const clientId = str(params, "client_id"); + if (clientId !== WEB_CLIENT_ID) throw new OAuthError("invalid_client", "unknown client_id", HttpStatus.UNAUTHORIZED); + const presented = str(params, "refresh_token"); + if (presented === undefined || presented === "") throw new OAuthError("invalid_request", "refresh_token is required"); + + const record = await this.deps.refreshStore.lookupByHash(sha256(presented).toString("base64url")); + if (record === null || record.clientId !== clientId) throw new OAuthError("invalid_grant", "refresh token is invalid"); + if (record.consumedAt !== null) { + await this.deps.refreshStore.revokeFamily(record.workspaceId, record.familyId); + throw new OAuthError("invalid_grant", "refresh token reuse detected; the chain has been revoked"); + } + if (!(await this.deps.refreshStore.sessionAlive(record.workspaceId, record.sid))) { + await this.deps.refreshStore.revokeFamily(record.workspaceId, record.familyId); + throw new OAuthError("invalid_grant", "the signing-in session has ended; sign in again"); + } + const user = await this.deps.users.activeMember(record.workspaceId, record.userId); + if (user === null) { + await this.deps.refreshStore.revokeFamily(record.workspaceId, record.familyId); + throw new OAuthError("invalid_grant", "membership is no longer active"); + } + + await this.deps.refreshStore.consume(record); + const scopes = record.scope.split(" ").filter((s) => s !== ""); + const keyring = await this.keyring(); + const iat = Math.floor(this.deps.now() / 1000); + const exp = iat + TOKEN_TTL_SECONDS; + const scope = record.scope; + const claims = this.userClaims(user, scopes); + const accessToken = keyring.sign( + { iss: config.issuer, aud: clientId, sub: user.id, client_id: clientId, scope, iat, exp, jti: randomUUID(), ...claims }, + "at+jwt", + ); + const idToken = keyring.sign({ + iss: config.issuer, aud: clientId, sub: user.id, iat, exp, at_hash: atHash(accessToken), ...claims, + }); + const rotated = randomBytes(32).toString("base64url"); + await this.deps.refreshStore.issue({ + workspaceId: record.workspaceId, + userId: record.userId, + sid: record.sid, + clientId: record.clientId, + scope: record.scope, + familyId: record.familyId, + tokenHash: sha256(rotated).toString("base64url"), + }); + return { + access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, + id_token: idToken, scope, refresh_token: rotated, + }; } /** GET /oidc/userinfo — claims for a valid access token (stateless: read from the token itself). */ diff --git a/apps/api/src/oidc/oidc.test.ts b/apps/api/src/oidc/oidc.test.ts index 6aa7a2c..5f6f09f 100644 --- a/apps/api/src/oidc/oidc.test.ts +++ b/apps/api/src/oidc/oidc.test.ts @@ -8,6 +8,7 @@ import { Vault, type SealedCredential } from "../vault/vault.js"; import { CODE_TTL_SECONDS, DEFAULT_ISSUER, OidcConfigError, WEB_REDIRECT_URI, loadOidcConfig, validateAuthorize, type OidcConfig } from "./oidc.config.js"; import { FileKeyStore, OidcKeyError, OidcKeyring, type KeySealer, type PublicJwk } from "./oidc.keys.js"; import { OidcService, type OidcUser, type OidcUserDirectory, type TokenResponse } from "./oidc.service.js"; +import { MemoryRefreshStore } from "./oidc.refresh-store.js"; const WORKSPACE = "11111111-1111-4111-8111-111111111111"; const ADA: OidcUser = { id: "22222222-2222-4222-8222-222222222222", username: "ada.l", email: "ada@example.org", displayName: "Ada Lovelace" }; @@ -71,19 +72,22 @@ afterEach(async () => { await rm(keyDir, { recursive: true, force: true }); }); -function harness(): { service: OidcService; users: FakeUsers; clock: { now: number }; config: OidcConfig } { +function harness(): { service: OidcService; users: FakeUsers; clock: { now: number }; config: OidcConfig; store: MemoryRefreshStore; sessions: Set } { const users = new FakeUsers(); const clock = { now: 1_800_000_000_000 }; const config: OidcConfig = { issuer: DEFAULT_ISSUER, consentUrl: new URL("https://app.occupymedia.cc/oidc/consent"), keyDir }; const vault = new Vault(); + const store = new MemoryRefreshStore(); + const sessions = store.liveSessions; const service = new OidcService({ config: () => config, keyring: (c) => OidcKeyring.open(new FileKeyStore(c.keyDir), vault), users, now: () => clock.now, reservedUsernames: () => new Set(["admin", "svc-files"]), + refreshStore: store, }); - return { service, users, clock, config }; + return { service, users, clock, config, store, sessions }; } async function issueCode(service: OidcService, challenge: string, extra: Record = {}): Promise { @@ -351,3 +355,78 @@ describe("OidcService", () => { expect(await restarted.service.userinfo(`Bearer ${tokens.access_token}`)).toMatchObject({ sub: ADA.id }); }); }); + +describe("OidcService refresh tokens (phase 2)", () => { + async function sessionBoundTokens(h: ReturnType) { + const sid = "33333333-3333-4333-8333-333333333333"; + h.sessions.add(`${WORKSPACE}:${sid}`); + const { verifier, challenge } = pkce(); + const code = await (async () => { + const { redirectTo } = await h.service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), sid); + const url = new URL(redirectTo); + const got = url.searchParams.get("code"); + if (got === null) throw new Error(`no code in redirect: ${url.searchParams.get("error") ?? ""}`); + return got; + })(); + return { sid, tokens: await exchange(h.service, code, verifier) }; + } + + const refresh = (h: ReturnType, token: string, extra: Record = {}) => + h.service.token({ grant_type: "refresh_token", client_id: "web", refresh_token: token, ...extra }); + + it("issues a refresh token bound to the consenting session; rotation returns fresh tokens each time", async () => { + const h = harness(); + const { sid, tokens } = await sessionBoundTokens(h); + expect(tokens.refresh_token).toBeDefined(); + const first = await refresh(h, tokens.refresh_token!); + expect(first.refresh_token).toBeDefined(); + expect(first.refresh_token).not.toBe(tokens.refresh_token); + expect(decode(first.id_token.split(".")[1]!)).toMatchObject({ sub: ADA.id, preferred_username: ADA.username }); + const rows = [...h.store.rows.values()]; + expect(rows).toHaveLength(2); + expect(rows.every((row) => row.sid === sid)).toBe(true); + expect(rows.filter((row) => row.consumedAt === null)).toHaveLength(1); + }); + + it("a code from a pre-phase2 session (no sid) mints no refresh token", async () => { + const h = harness(); + const { verifier, challenge } = pkce(); + const tokens = await exchange(h.service, await issueCode(h.service, challenge), verifier); + expect(tokens.refresh_token).toBeUndefined(); + }); + + it("rejects a revoked (logged-out) session's refresh token and revokes the chain", async () => { + const h = harness(); + const { sid, tokens } = await sessionBoundTokens(h); + h.sessions.delete(`${WORKSPACE}:${sid}`); + await expect(refresh(h, tokens.refresh_token!)).rejects.toMatchObject({ response: { error: "invalid_grant" } } as never); + expect(h.store.rows.size).toBe(0); + }); + + it("reuse of a consumed token revokes the whole family", async () => { + const h = harness(); + const { tokens } = await sessionBoundTokens(h); + const used = tokens.refresh_token!; + await refresh(h, used); + await expect(refresh(h, used)).rejects.toMatchObject({ response: { error: "invalid_grant" } } as never); + expect(h.store.rows.size).toBe(0); + }); + + it("an inactive member's chain dies at refresh", async () => { + const h = harness(); + const { tokens } = await sessionBoundTokens(h); + h.users.members.delete(ADA.id); + await expect(refresh(h, tokens.refresh_token!)).rejects.toMatchObject({ response: { error: "invalid_grant" } } as never); + expect(h.store.rows.size).toBe(0); + }); + + it("discovery advertises the refresh grant; unknown clients and missing tokens fail closed", async () => { + const h = harness(); + expect(h.service.discovery().grant_types_supported).toEqual(["authorization_code", "refresh_token"]); + const { tokens } = await sessionBoundTokens(h); + await expect(h.service.token({ grant_type: "refresh_token", client_id: "nope", refresh_token: tokens.refresh_token })) + .rejects.toMatchObject({ response: { error: "invalid_client" } } as never); + await expect(h.service.token({ grant_type: "refresh_token", client_id: "web" })) + .rejects.toMatchObject({ response: { error: "invalid_request" } } as never); + }); +});