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:
glass beadian 2026-10-14 11:33:47 +00:00
parent d6eb39ff27
commit 643fbb94bc
10 changed files with 601 additions and 19 deletions

View file

@ -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 };
}
}

View file

@ -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(

View file

@ -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 });
});

View 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);
});
});

View file

@ -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 {

View file

@ -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")

View 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>();
}

View file

@ -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). */

View file

@ -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);
});
});