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