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:
glass beadian 2026-10-13 18:32:38 +00:00
parent 6145281b3f
commit 96e5a63dd8
11 changed files with 1501 additions and 8 deletions

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