diff --git a/src/index.ts b/src/index.ts index 17a8309c4..c0d40842e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -630,11 +630,10 @@ async function handleExchange(request: Request, env: Env, traceId: string): Prom const authorization = request.headers.get("authorization") || ""; const match = authorization.match(/^Bearer\s+(.+)$/i); if (!match) throw new ApiError("ERR_AUTH_MISSING", 401, "Missing bearer token"); - /* v8 ignore start */ const claims = await verifyGithubOidcJwt(match[1], env); const oidc_sub = claims.sub ? safeHash(claims.sub).slice(0, 16) : undefined; const { repository, token, token_expires_at, replay_protected } = await createRepositoryInstallationToken(request, claims, env); - const workflow_ref = claims.job_workflow_ref || claims.workflow_ref || ""; + const workflow_ref = claims.job_workflow_ref || claims.workflow_ref!; const response = successResponse( { token, repository, workflow_ref, token_expires_at }, traceId, @@ -650,7 +649,6 @@ async function handleExchange(request: Request, env: Env, traceId: string): Prom token_expires_at, response, }; - /* v8 ignore stop */ } /** diff --git a/test/exchange-success-path-coverage.test.ts b/test/exchange-success-path-coverage.test.ts new file mode 100644 index 000000000..6c454fe55 --- /dev/null +++ b/test/exchange-success-path-coverage.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker, { type Env } from "../src/index"; + +const configuredRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; + +const env: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(bytes: ArrayBuffer): string { + return Buffer.from(bytes).toString("base64url"); +} + +function pemFromPkcs8(pkcs8: ArrayBuffer): string { + const base64 = Buffer.from(pkcs8).toString("base64"); + const lines = base64.match(/.{1,64}/g)?.join("\n") ?? base64; + return `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; +} + +async function createSignedJwt(payload: Record) { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const kid = `exchange-success-${crypto.randomUUID()}`; + const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); + const body = encodeSegment(payload); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + keyPair.privateKey, + new TextEncoder().encode(`${header}.${body}`), + ); + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + return { + token: `${header}.${body}.${encodeBytes(signature)}`, + jwk: { ...publicJwk, kid, kty: "RSA" }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("exchange success-path coverage through the public worker", () => { + it("accepts workflow_ref-only claims without inventing an OIDC subject", async () => { + const now = Math.floor(Date.now() / 1000); + const { token: oidcToken, jwk } = await createSignedJwt({ + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + workflow_ref: configuredRef, + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const appKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const appPrivateKey = pemFromPkcs8( + await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey), + ); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ + jwks_uri: "https://token.actions.githubusercontent.com/.well-known/jwks", + }); + } + if (url === "https://token.actions.githubusercontent.com/.well-known/jwks") { + return Response.json({ keys: [jwk] }); + } + if (url === "https://api.github.com/repos/ContextualWisdomLab/noema/installation") { + return Response.json({ id: 12345 }); + } + if (url === "https://api.github.com/app/installations/12345/access_tokens") { + return Response.json({ + token: "ghs_exchange_success_token", + expires_at: "2030-01-01T00:00:00Z", + }); + } + return new Response("not found", { status: 404 }); + }); + + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${oidcToken}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.205", + }, + body: JSON.stringify({ target_repository: "ContextualWisdomLab/noema" }), + }), + { + ...env, + GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKey, + }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + data: { + token: "ghs_exchange_success_token", + repository: "ContextualWisdomLab/noema", + workflow_ref: configuredRef, + token_expires_at: "2030-01-01T00:00:00Z", + }, + }); + const logOutput = logSpy.mock.calls.flat().join("\n"); + expect(logOutput).not.toContain("ghs_exchange_success_token"); + expect(logOutput).not.toContain(oidcToken); + expect(logOutput).not.toContain("oidc_sub"); + }); +}); diff --git a/test/production-coverage-policy.test.ts b/test/production-coverage-policy.test.ts index 951ee1a8e..a97410977 100644 --- a/test/production-coverage-policy.test.ts +++ b/test/production-coverage-policy.test.ts @@ -26,4 +26,17 @@ describe("production coverage policy", () => { expect(configuration).toContain(`${metric}: 100`); } }); + + it("keeps the public credential-exchange success path inside measured production coverage", () => { + const source = readFileSync("src/index.ts", "utf8"); + const handleExchangeStart = source.indexOf("async function handleExchange"); + const workerEntrypointStart = source.indexOf("/**\n * Base public Worker entrypoint", handleExchangeStart); + + expect(handleExchangeStart).toBeGreaterThanOrEqual(0); + expect(workerEntrypointStart).toBeGreaterThan(handleExchangeStart); + + const handleExchangeSource = source.slice(handleExchangeStart, workerEntrypointStart); + expect(handleExchangeSource).not.toContain("/* v8 ignore start */"); + expect(handleExchangeSource).not.toContain("/* v8 ignore stop */"); + }); }); \ No newline at end of file