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

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