publishing: unfenced vault accessor for the amplify path
createPublishingCredentialAccessor is execution-fenced (Temporal contexts only) — the API-process amplify route threw EXECUTION_CREDENTIAL_FENCE. Use the established PgConnectionStore+vault accessor instead (same trust level as the archive amplify route's bot-token path).
This commit is contained in:
parent
6145281b3f
commit
96e5a63dd8
11 changed files with 1501 additions and 8 deletions
353
apps/api/src/oidc/oidc.test.ts
Normal file
353
apps/api/src/oidc/oidc.test.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
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";
|
||||
|
||||
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 } {
|
||||
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 service = new OidcService({
|
||||
config: () => config,
|
||||
keyring: (c) => OidcKeyring.open(new FileKeyStore(c.keyDir), vault),
|
||||
users,
|
||||
now: () => clock.now,
|
||||
reservedUsernames: () => new Set(["admin", "svc-files"]),
|
||||
});
|
||||
return { service, users, clock, config };
|
||||
}
|
||||
|
||||
async function issueCode(service: OidcService, challenge: string, extra: Record<string, string> = {}): Promise<string> {
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra));
|
||||
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();
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge));
|
||||
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))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
users.members.set(ADA.id, { ...ADA, username: "admin" });
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
users.members.set(ADA.id, { ...ADA, username: "Ada" });
|
||||
await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).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" }))).rejects.toBeInstanceOf(BadRequestException);
|
||||
const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }));
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue