T-HUB/apps/api/src/oidc/oidc.test.ts
glass beadian 74f0802f1a fix: oidc hardening — sid-less consent refused, rotation race closed, iss on errors, logout honesty
From the logic audit:
- consent rejects sid-less bearers and requires a LIVE hub session
  (sessionAlive) before minting codes — a logged-out bearer can no longer
  mint downstream tokens inside its TTL
- refresh rotation race closed: consume returns whether THIS call won;
  the loser revokes the family (reuse detection now holds under
  concurrency)
- error redirects carry iss (RFC 9207) on error responses too
- logout awaits the revocation and no longer returns ok for missing or
  forged bearers
2026-10-18 11:54:59 +00:00

434 lines
22 KiB
TypeScript

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<string, string> = {}): Record<string, string> {
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<string, unknown> {
return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record<string, unknown>;
}
/** Independent ES256 check against the published JWKS (what oCIS does). */
function verifiedBy(jwks: { keys: PublicJwk[] }, token: string): Record<string, unknown> {
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<string, OidcUser>([[ADA.id, ADA]]);
async activeMember(workspaceId: string, userId: string): Promise<OidcUser | null> {
return workspaceId === WORKSPACE ? (this.members.get(userId) ?? null) : null;
}
}
/** Distinct KEK → the sealed keyset must not open. */
class OtherKekSealer implements KeySealer {
async seal(): Promise<SealedCredential> {
throw new Error("unused");
}
async open(): Promise<Buffer> {
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<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;
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<string, string> = {}): Promise<string> {
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<string, string> = {}): Promise<TokenResponse> {
const body: Record<string, string> = { 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<unknown>): 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<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 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);
});
});