feat: OIDC phase 2 — session-bound refresh tokens, server-side logout
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.
This commit is contained in:
parent
d6eb39ff27
commit
643fbb94bc
10 changed files with 601 additions and 19 deletions
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<AuthResult> {
|
||||
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<void> {
|
||||
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<MeResponse> {
|
||||
return withTenant(workspaceId, async (client) => {
|
||||
const res = await client.query(
|
||||
|
|
|
|||
|
|
@ -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<unknown>) => 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 });
|
||||
});
|
||||
|
|
|
|||
109
apps/api/src/auth/session-lifecycle.integration.test.ts
Normal file
109
apps/api/src/auth/session-lifecycle.integration.test.ts
Normal file
|
|
@ -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<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
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<unknown>): Promise<boolean> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
171
apps/api/src/oidc/oidc.refresh-store.ts
Normal file
171
apps/api/src/oidc/oidc.refresh-store.ts
Normal file
|
|
@ -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<void>;
|
||||
lookupByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
|
||||
consume(record: RefreshTokenRecord): Promise<void>;
|
||||
/** Reuse detection: delete the whole chain; returns rows removed. */
|
||||
revokeFamily(workspaceId: string, familyId: string): Promise<number>;
|
||||
/** The minting Hub session must still be live (not revoked, not expired). */
|
||||
sessionAlive(workspaceId: string, sid: string): Promise<boolean>;
|
||||
/** Drop rows whose session is gone (best-effort hygiene, same tenant). */
|
||||
sweepOrphans(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<RefreshTokenRecord | null> {
|
||||
const row = (await withSystem((client) => client.query<Row>("SELECT * FROM oidc_refresh_lookup($1)", [tokenHash]))).rows[0];
|
||||
return row === undefined ? null : fromRow(row);
|
||||
}
|
||||
|
||||
async consume(record: RefreshTokenRecord): Promise<void> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string, RefreshTokenRecord>();
|
||||
|
||||
async issue(input: RefreshIssueInput): Promise<void> {
|
||||
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<RefreshTokenRecord | null> {
|
||||
return this.rows.get(tokenHash) ?? null;
|
||||
}
|
||||
|
||||
async consume(record: RefreshTokenRecord): Promise<void> {
|
||||
for (const row of this.rows.values()) if (row.id === record.id) row.consumedAt = new Date();
|
||||
}
|
||||
|
||||
async revokeFamily(workspaceId: string, familyId: string): Promise<number> {
|
||||
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<boolean> {
|
||||
return this.liveSessions.has(`${workspaceId}:${sid}`);
|
||||
}
|
||||
|
||||
async sweepOrphans(): Promise<void> {}
|
||||
|
||||
/** Test control: which (workspace, sid) pairs count as live sessions. */
|
||||
readonly liveSessions = new Set<string>();
|
||||
}
|
||||
|
|
@ -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<string>;
|
||||
/** 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<TokenResponse> {
|
||||
const config = this.config();
|
||||
const params = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>) : {};
|
||||
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<string> {
|
||||
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<string, unknown>): Promise<TokenResponse> {
|
||||
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). */
|
||||
|
|
|
|||
|
|
@ -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<string> } {
|
||||
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<string, string> = {}): Promise<string> {
|
||||
|
|
@ -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<typeof harness>) {
|
||||
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<typeof harness>, token: string, extra: Record<string, string> = {}) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue