import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { BadRequestException, ForbiddenException, HttpException, UnauthorizedException } from "@nestjs/common"; 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" }; function pkce(): { verifier: string; challenge: string } { const verifier = randomBytes(32).toString("base64url"); return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") }; } function authorizeParams(challenge: string, extra: Record = {}): Record { return { response_type: "code", client_id: "web", redirect_uri: WEB_REDIRECT_URI, scope: "openid profile email", state: "st-123", nonce: "n-456", code_challenge: challenge, code_challenge_method: "S256", ...extra, }; } function decode(part: string): Record { return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record; } /** Independent ES256 check against the published JWKS (what oCIS does). */ function verifiedBy(jwks: { keys: PublicJwk[] }, token: string): Record { const [header, payload, signature] = token.split(".") as [string, string, string]; const jwk = jwks.keys.find((k) => k.kid === decode(header).kid); if (jwk === undefined) throw new Error("kid not in JWKS"); const key = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: "jwk" }); const ok = verify("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" }, Buffer.from(signature, "base64url")); if (!ok) throw new Error("signature did not verify"); return decode(payload); } class FakeUsers implements OidcUserDirectory { readonly members = new Map([[ADA.id, ADA]]); async activeMember(workspaceId: string, userId: string): Promise { return workspaceId === WORKSPACE ? (this.members.get(userId) ?? null) : null; } } /** Distinct KEK → the sealed keyset must not open. */ class OtherKekSealer implements KeySealer { async seal(): Promise { throw new Error("unused"); } async open(): Promise { throw new Error("unsupported state or unable to authenticate data"); } } let keyDir: string; beforeEach(async () => { keyDir = await mkdtemp(join(tmpdir(), "hub-oidc-")); }); afterEach(async () => { await rm(keyDir, { recursive: true, force: true }); }); function harness(): { service: OidcService; users: FakeUsers; clock: { now: number }; config: OidcConfig; store: MemoryRefreshStore; sessions: Set } { 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; store.liveSessions.add(`${WORKSPACE}:test-sid`); store.liveSessions.add(`${WORKSPACE}:test-sid`); 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, store, sessions }; } async function issueCode(service: OidcService, challenge: string, extra: Record = {}): Promise { const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra), "test-sid"); const url = new URL(redirectTo); const code = url.searchParams.get("code"); if (code === null) throw new Error(`no code in redirect: ${url.searchParams.get("error") ?? ""}`); return code; } function exchange(service: OidcService, code: string, verifier: string | undefined, extra: Record = {}): Promise { const body: Record = { grant_type: "authorization_code", client_id: "web", redirect_uri: WEB_REDIRECT_URI, code, ...extra }; if (verifier !== undefined) body.code_verifier = verifier; return service.token(body); } async function oauthError(promise: Promise): Promise<{ status: number; error: string }> { const err = await promise.then(() => null, (e: unknown) => e); if (!(err instanceof HttpException)) throw new Error("expected an HttpException"); const body = err.getResponse() as { error?: string }; return { status: err.getStatus(), error: body.error ?? "" }; } describe("loadOidcConfig", () => { it("defaults to the production issuer and consent page", () => { const config = loadOidcConfig({ OIDC_KEY_DIR: "/var/lib/hub/oidc" }); expect(config.issuer).toBe("https://api.occupymedia.cc/oidc"); expect(config.consentUrl.toString()).toBe("https://app.occupymedia.cc/oidc/consent"); }); it("requires a key volume in production and refuses non-https issuers", () => { expect(() => loadOidcConfig({ NODE_ENV: "production" })).toThrow(OidcConfigError); expect(() => loadOidcConfig({ OIDC_KEY_DIR: "/k", OIDC_ISSUER: "http://api.example.org/oidc" })).toThrow(OidcConfigError); expect(loadOidcConfig({ OIDC_KEY_DIR: "/k", OIDC_ISSUER: "http://localhost:3000/oidc/" }).issuer).toBe("http://localhost:3000/oidc"); }); }); describe("validateAuthorize", () => { const { challenge } = pkce(); it("never redirects to an unknown client or an unregistered redirect_uri", () => { expect(validateAuthorize(authorizeParams(challenge, { client_id: "evil" }))).toMatchObject({ ok: false, redirect: false, error: "invalid_client" }); for (const redirect_uri of [`${WEB_REDIRECT_URI}?x=1`, "https://files.occupymedia.cc/oidc-callback.html/", "https://evil.example/oidc-callback.html"]) { expect(validateAuthorize(authorizeParams(challenge, { redirect_uri }))).toMatchObject({ ok: false, redirect: false }); } }); it("requires PKCE S256 (missing challenge or plain method is rejected)", () => { const missing = authorizeParams(challenge); delete missing.code_challenge; expect(validateAuthorize(missing)).toMatchObject({ ok: false, redirect: true, error: "invalid_request", state: "st-123" }); expect(validateAuthorize(authorizeParams(challenge, { code_challenge_method: "plain" }))).toMatchObject({ ok: false, redirect: true }); expect(validateAuthorize(authorizeParams("too-short", {}))).toMatchObject({ ok: false, redirect: true }); }); it("requires openid and refuses unknown scopes", () => { expect(validateAuthorize(authorizeParams(challenge, { scope: "profile email" }))).toMatchObject({ ok: false, error: "invalid_scope" }); expect(validateAuthorize(authorizeParams(challenge, { scope: "openid offline_access" }))).toMatchObject({ ok: false, error: "invalid_scope" }); expect(validateAuthorize(authorizeParams(challenge))).toMatchObject({ ok: true, request: { scopes: ["openid", "profile", "email"] } }); }); }); describe("OidcKeyring persistence", () => { it("generates on first boot, then reloads the same key across restarts", async () => { const vault = new Vault(); const first = await OidcKeyring.open(new FileKeyStore(keyDir), vault); const token = first.sign({ hello: "world" }); const restarted = await OidcKeyring.open(new FileKeyStore(keyDir), new Vault()); expect(restarted.signingKid).toBe(first.signingKid); expect(restarted.jwks()).toEqual(first.jwks()); expect(restarted.verify(token)?.payload).toEqual({ hello: "world" }); }); it("stores the keyset sealed (no private JWK on disk) with owner-only permissions", async () => { await OidcKeyring.open(new FileKeyStore(keyDir), new Vault()); const file = join(keyDir, "keyset.sealed.json"); const raw = await readFile(file, "utf8"); expect(raw).not.toContain("privateJwk"); expect(raw).not.toContain("\"d\""); expect((await stat(file)).mode & 0o777).toBe(0o600); }); it("converges on one key when two processes boot at once", async () => { const [a, b] = await Promise.all([ OidcKeyring.open(new FileKeyStore(keyDir), new Vault()), OidcKeyring.open(new FileKeyStore(keyDir), new Vault()), ]); expect(a.signingKid).toBe(b.signingKid); }); it("refuses to silently regenerate when the keyset cannot be unsealed", async () => { await OidcKeyring.open(new FileKeyStore(keyDir), new Vault()); await expect(OidcKeyring.open(new FileKeyStore(keyDir), new OtherKekSealer())).rejects.toBeInstanceOf(OidcKeyError); }); it("rejects tokens with a tampered payload or a foreign key", async () => { const ring = await OidcKeyring.open(new FileKeyStore(keyDir), new Vault()); const [header, , signature] = ring.sign({ sub: "a" }).split("."); expect(ring.verify(`${header}.${Buffer.from(JSON.stringify({ sub: "b" })).toString("base64url")}.${signature}`)).toBeNull(); const otherDir = await mkdtemp(join(tmpdir(), "hub-oidc-other-")); try { const other = await OidcKeyring.open(new FileKeyStore(otherDir), new Vault()); expect(ring.verify(other.sign({ sub: "a" }))).toBeNull(); } finally { await rm(otherDir, { recursive: true, force: true }); } }); }); describe("OidcService", () => { it("publishes discovery for the exact issuer", () => { const doc = harness().service.discovery(); expect(doc.issuer).toBe("https://api.occupymedia.cc/oidc"); expect(doc.authorization_endpoint).toBe("https://api.occupymedia.cc/oidc/authorize"); expect(doc.token_endpoint).toBe("https://api.occupymedia.cc/oidc/token"); expect(doc.userinfo_endpoint).toBe("https://api.occupymedia.cc/oidc/userinfo"); expect(doc.jwks_uri).toBe("https://api.occupymedia.cc/oidc/jwks"); expect(doc.code_challenge_methods_supported).toEqual(["S256"]); expect(doc.id_token_signing_alg_values_supported).toEqual(["ES256"]); }); it("GET /authorize forwards valid requests to the Hub consent page and bad clients to a 400", () => { const { service } = harness(); const { challenge } = pkce(); const url = new URL(service.authorizeRedirect({ ...authorizeParams(challenge), junk: "x" })); expect(`${url.origin}${url.pathname}`).toBe("https://app.occupymedia.cc/oidc/consent"); expect(url.searchParams.get("code_challenge")).toBe(challenge); expect(url.searchParams.get("state")).toBe("st-123"); expect(url.searchParams.has("junk")).toBe(false); const noPkce = new URL(service.authorizeRedirect(authorizeParams(challenge, { code_challenge_method: "plain" }))); expect(`${noPkce.origin}${noPkce.pathname}`).toBe(WEB_REDIRECT_URI); expect(noPkce.searchParams.get("error")).toBe("invalid_request"); expect(() => service.authorizeRedirect(authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }))).toThrow(BadRequestException); }); it("auto-consents for client_id=web: the session gets a code straight back, no consent screen", async () => { const { service } = harness(); const { challenge } = pkce(); await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException); // sid-less bearers cannot mint const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid"); const url = new URL(redirectTo); expect(`${url.origin}${url.pathname}`).toBe(WEB_REDIRECT_URI); expect(url.searchParams.get("code")).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(url.searchParams.get("state")).toBe("st-123"); expect(url.searchParams.get("iss")).toBe(DEFAULT_ISSUER); }); it("only issues codes to active members with a usable, unreserved username", async () => { const { service, users } = harness(); const { challenge } = pkce(); await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException); await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(UnauthorizedException); // cross-workspace sid is not live users.members.set(ADA.id, { ...ADA, username: "admin" }); await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException); users.members.set(ADA.id, { ...ADA, username: "Ada" }); await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException); }); it("re-validates relayed params: a forged redirect_uri from the consent page is refused", async () => { const { service } = harness(); const { challenge } = pkce(); await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }), "test-sid")).rejects.toBeInstanceOf(BadRequestException); const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }), "test-sid"); expect(new URL(redirectTo).searchParams.has("code")).toBe(false); }); it("maps Hub identity into ES256 id_token / access_token claims", async () => { const { service, clock } = harness(); const { verifier, challenge } = pkce(); const tokens = await exchange(service, await issueCode(service, challenge), verifier); expect(tokens.token_type).toBe("Bearer"); expect(tokens.scope).toBe("openid profile email"); const jwks = await service.jwks(); expect(decode(tokens.id_token.split(".")[0]!)).toMatchObject({ alg: "ES256", typ: "JWT", kid: jwks.keys[0]!.kid }); const iat = Math.floor(clock.now / 1000); const idClaims = verifiedBy(jwks, tokens.id_token); expect(idClaims).toMatchObject({ iss: "https://api.occupymedia.cc/oidc", aud: "web", sub: ADA.id, preferred_username: "ada.l", email: "ada@example.org", name: "Ada Lovelace", nonce: "n-456", iat, exp: iat + tokens.expires_in, }); expect(idClaims.at_hash).toBe(createHash("sha256").update(tokens.access_token).digest().subarray(0, 16).toString("base64url")); expect(decode(tokens.access_token.split(".")[0]!).typ).toBe("at+jwt"); expect(verifiedBy(jwks, tokens.access_token)).toMatchObject({ iss: DEFAULT_ISSUER, sub: ADA.id, client_id: "web", preferred_username: "ada.l" }); expect(await service.userinfo(`Bearer ${tokens.access_token}`)).toEqual({ sub: ADA.id, preferred_username: "ada.l", name: "Ada Lovelace", email: "ada@example.org", }); }); it("omits name/email when their scopes were not requested", async () => { const { service } = harness(); const { verifier, challenge } = pkce(); const tokens = await exchange(service, await issueCode(service, challenge, { scope: "openid" }), verifier); const claims = verifiedBy(await service.jwks(), tokens.id_token); expect(claims.preferred_username).toBe("ada.l"); expect(claims).not.toHaveProperty("email"); expect(claims).not.toHaveProperty("name"); }); it("enforces PKCE: a missing or wrong verifier is rejected (and burns the code)", async () => { const { service } = harness(); const { verifier, challenge } = pkce(); const missing = await issueCode(service, challenge); expect(await oauthError(exchange(service, missing, undefined))).toEqual({ status: 400, error: "invalid_grant" }); const wrong = await issueCode(service, challenge); expect(await oauthError(exchange(service, wrong, pkce().verifier))).toEqual({ status: 400, error: "invalid_grant" }); expect(await oauthError(exchange(service, wrong, verifier))).toEqual({ status: 400, error: "invalid_grant" }); }); it("codes are single-use", async () => { const { service } = harness(); const { verifier, challenge } = pkce(); const code = await issueCode(service, challenge); await exchange(service, code, verifier); expect(await oauthError(exchange(service, code, verifier))).toEqual({ status: 400, error: "invalid_grant" }); }); it(`codes expire after ${CODE_TTL_SECONDS}s`, async () => { const { service, clock } = harness(); const { verifier, challenge } = pkce(); const code = await issueCode(service, challenge); clock.now += CODE_TTL_SECONDS * 1000; expect(await oauthError(exchange(service, code, verifier))).toEqual({ status: 400, error: "invalid_grant" }); }); it("binds the code to its client and redirect_uri", async () => { const { service } = harness(); const { verifier, challenge } = pkce(); expect(await oauthError(exchange(service, await issueCode(service, challenge), verifier, { client_id: "other" }))).toEqual({ status: 401, error: "invalid_client" }); expect(await oauthError(exchange(service, await issueCode(service, challenge), verifier, { redirect_uri: "https://files.occupymedia.cc/" }))).toEqual({ status: 400, error: "invalid_grant" }); expect(await oauthError(service.token({ grant_type: "password", client_id: "web" }))).toEqual({ status: 400, error: "unsupported_grant_type" }); }); it("userinfo refuses id_tokens, expired tokens and garbage", async () => { const { service, clock } = harness(); const { verifier, challenge } = pkce(); const tokens = await exchange(service, await issueCode(service, challenge), verifier); await expect(service.userinfo(undefined)).rejects.toBeInstanceOf(UnauthorizedException); await expect(service.userinfo("Bearer not.a.jwt")).rejects.toBeInstanceOf(UnauthorizedException); await expect(service.userinfo(`Bearer ${tokens.id_token}`)).rejects.toBeInstanceOf(UnauthorizedException); clock.now += (tokens.expires_in + 1) * 1000; await expect(service.userinfo(`Bearer ${tokens.access_token}`)).rejects.toBeInstanceOf(UnauthorizedException); }); it("keeps the same signing key across a service restart, so issued tokens stay valid", async () => { const first = harness(); const { verifier, challenge } = pkce(); const tokens = await exchange(first.service, await issueCode(first.service, challenge), verifier); const restarted = harness(); expect(await restarted.service.userinfo(`Bearer ${tokens.access_token}`)).toMatchObject({ sub: ADA.id }); }); }); describe("OidcService refresh tokens (phase 2)", () => { async function sessionBoundTokens(h: ReturnType) { 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, token: string, extra: Record = {}) => 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 consent without a session id (pre-phase2 client) is rejected outright", async () => { const { service } = harness(); const { challenge } = pkce(); await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException); }); 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); }); });