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

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