From 89984535dd69f87d672d4260a2a2b92457c485ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:38:25 +0900 Subject: [PATCH 01/24] test(coverage): require OIDC core instrumentation --- test/coverage-ignore-operational-helpers.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/coverage-ignore-operational-helpers.test.ts b/test/coverage-ignore-operational-helpers.test.ts index 4a817d1f7..8061b1e72 100644 --- a/test/coverage-ignore-operational-helpers.test.ts +++ b/test/coverage-ignore-operational-helpers.test.ts @@ -21,6 +21,11 @@ describe("owned production coverage exclusions", () => { "errorResponse", "withOperationalHeaders", "logRequest", + "base64UrlDecode", + "base64UrlEncode", + "decodeJson", + "fetchGithubOidcKeys", + "verifyGithubOidcJwt", "validateRepositoryName", "parseExchangeRequestBody", ])("keeps %s inside measured production coverage", (functionName) => { From e66c8801081fefc6fd97dd80d4e7a673414f487c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:41:15 +0900 Subject: [PATCH 02/24] fix(coverage): measure OIDC verification core --- src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2c0caecf6..8652527c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -309,7 +309,6 @@ function logRequest({ console.log(JSON.stringify(payload)); } -/* v8 ignore start */ function base64UrlDecode(input: string): Uint8Array { const padded = input.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((input.length + 3) % 4); const binary = atob(padded); @@ -409,7 +408,6 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise throw new ApiError("ERR_OIDC_VERIFICATION", 401, "OIDC token verification failed"); } } -/* v8 ignore stop */ function validateRepositoryName(repository: string, env: Env): string { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { @@ -717,4 +715,4 @@ export default { return withOperationalHeaders(response, traceId, latency_ms); } }, -}; +}; \ No newline at end of file From 4c77e15b90fd4f00f1208f6b1517a2ad6f6c5dcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:57:06 +0900 Subject: [PATCH 03/24] test(coverage): preserve canonical index newline --- test/coverage-ignore-operational-helpers.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/coverage-ignore-operational-helpers.test.ts b/test/coverage-ignore-operational-helpers.test.ts index 8061b1e72..afb932930 100644 --- a/test/coverage-ignore-operational-helpers.test.ts +++ b/test/coverage-ignore-operational-helpers.test.ts @@ -34,4 +34,8 @@ describe("owned production coverage exclusions", () => { `${functionName} must not be hidden by a broad v8 ignore region`, ).toBe(false); }); + + it("keeps the owned production module canonically newline-terminated", () => { + expect(source.endsWith("\n"), "src/index.ts must end with a newline").toBe(true); + }); }); From da6af5bb0a4d144a23a040885b20d20753c3a443 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:58:51 +0900 Subject: [PATCH 04/24] fix(coverage): restore canonical index newline --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 8652527c4..9acca8ac2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -715,4 +715,4 @@ export default { return withOperationalHeaders(response, traceId, latency_ms); } }, -}; \ No newline at end of file +}; From a8cab937300074b1a80ea3fa434b9bda997cc6c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:08:18 +0900 Subject: [PATCH 05/24] test(oidc): distinguish malformed upstream documents --- test/oidc-upstream-failure-contract.test.ts | 88 +++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 test/oidc-upstream-failure-contract.test.ts diff --git a/test/oidc-upstream-failure-contract.test.ts b/test/oidc-upstream-failure-contract.test.ts new file mode 100644 index 000000000..16cb515fd --- /dev/null +++ b/test/oidc-upstream-failure-contract.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const 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: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + 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 structurallyValidJwt(): string { + const now = Math.floor(Date.now() / 1000); + return [ + encodeSegment({ alg: "RS256", kid: "upstream-json-contract" }), + encodeSegment({ + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }), + "AA", + ].join("."); +} + +async function exchangeWith(fetchImpl: typeof fetch): Promise { + vi.resetModules(); + const { default: worker } = await import("../src/index"); + vi.spyOn(globalThis, "fetch").mockImplementation(fetchImpl); + return worker.fetch(new Request("https://noema.example/exchange", { + method: "POST", + headers: { authorization: `Bearer ${structurallyValidJwt()}` }, + }), env); +} + +describe("OIDC upstream document failures", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("classifies malformed discovery JSON as upstream verification failure", async () => { + const response = await exchangeWith(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return new Response("{", { status: 200, headers: { "content-type": "application/json" } }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC discovery document was not valid JSON", + }); + }); + + it("classifies malformed JWKS JSON as upstream verification failure", async () => { + const response = await exchangeWith(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 new Response("{", { status: 200, headers: { "content-type": "application/json" } }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC JWKS was not valid JSON", + }); + }); +}); From 5260b95b7215a4b917a07e16e50874d2848f6f25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:10:58 +0900 Subject: [PATCH 06/24] fix(oidc): classify malformed upstream JSON correctly --- src/index.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9acca8ac2..3f5264c41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -336,11 +336,22 @@ async function fetchGithubOidcKeys(env: Env, forceRefresh = false): Promise Date: Mon, 17 Aug 2026 07:12:05 +0900 Subject: [PATCH 07/24] test(oidc): fail closed on malformed upstream schemas --- test/oidc-upstream-failure-contract.test.ts | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/oidc-upstream-failure-contract.test.ts b/test/oidc-upstream-failure-contract.test.ts index 16cb515fd..f0791f97a 100644 --- a/test/oidc-upstream-failure-contract.test.ts +++ b/test/oidc-upstream-failure-contract.test.ts @@ -66,6 +66,23 @@ describe("OIDC upstream document failures", () => { }); }); + it("rejects a discovery document whose jwks_uri is not a string", async () => { + const response = await exchangeWith(async (input) => { + const url = String(input); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ jwks_uri: 7 }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC discovery document did not include a valid jwks_uri", + }); + }); + it("classifies malformed JWKS JSON as upstream verification failure", async () => { const response = await exchangeWith(async (input) => { const url = String(input); @@ -85,4 +102,24 @@ describe("OIDC upstream document failures", () => { message: "GitHub OIDC JWKS was not valid JSON", }); }); + + it("rejects JWKS JSON whose keys member is not an array", async () => { + const response = await exchangeWith(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: null }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC JWKS did not include a valid keys array", + }); + }); }); From 81b5e72d549b3c930638cb510867958b34332ba5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:13:25 +0900 Subject: [PATCH 08/24] fix(oidc): validate upstream discovery and JWKS schemas --- src/index.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3f5264c41..4a055073b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -336,14 +336,16 @@ async function fetchGithubOidcKeys(env: Env, forceRefresh = false): Promise Date: Mon, 17 Aug 2026 07:31:47 +0900 Subject: [PATCH 09/24] test(security): reject off-origin OIDC JWKS discovery --- test/oidc-upstream-failure-contract.test.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/oidc-upstream-failure-contract.test.ts b/test/oidc-upstream-failure-contract.test.ts index f0791f97a..1b4f8c77f 100644 --- a/test/oidc-upstream-failure-contract.test.ts +++ b/test/oidc-upstream-failure-contract.test.ts @@ -83,6 +83,28 @@ describe("OIDC upstream document failures", () => { }); }); + it("rejects a discovery document that redirects JWKS retrieval off the trusted GitHub OIDC origin", async () => { + const fetchedUrls: string[] = []; + const response = await exchangeWith(async (input) => { + const url = String(input); + fetchedUrls.push(url); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ jwks_uri: "https://attacker.example/.well-known/jwks" }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC discovery document included an untrusted jwks_uri", + }); + expect(fetchedUrls).toEqual([ + "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + ]); + }); + it("classifies malformed JWKS JSON as upstream verification failure", async () => { const response = await exchangeWith(async (input) => { const url = String(input); From cd22d8325f471075fb6a5fb0cd7f922f80d004d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 07:34:40 +0900 Subject: [PATCH 10/24] fix(security): pin OIDC JWKS to trusted GitHub origin --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index 4a055073b..a0044b87a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -346,6 +346,9 @@ async function fetchGithubOidcKeys(env: Env, forceRefresh = false): Promise Date: Mon, 17 Aug 2026 09:38:13 +0900 Subject: [PATCH 11/24] test(oidc): cover empty JWKS discovery URI --- test/oidc-upstream-failure-contract.test.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/oidc-upstream-failure-contract.test.ts b/test/oidc-upstream-failure-contract.test.ts index 1b4f8c77f..05e0e51e0 100644 --- a/test/oidc-upstream-failure-contract.test.ts +++ b/test/oidc-upstream-failure-contract.test.ts @@ -83,6 +83,28 @@ describe("OIDC upstream document failures", () => { }); }); + it("rejects an empty discovery jwks_uri before attempting a JWKS fetch", async () => { + const fetchedUrls: string[] = []; + const response = await exchangeWith(async (input) => { + const url = String(input); + fetchedUrls.push(url); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return Response.json({ jwks_uri: "" }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC discovery document did not include a valid jwks_uri", + }); + expect(fetchedUrls).toEqual([ + "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + ]); + }); + it("rejects a discovery document that redirects JWKS retrieval off the trusted GitHub OIDC origin", async () => { const fetchedUrls: string[] = []; const response = await exchangeWith(async (input) => { From a311051c80f0d501a85574440ba5a08db1b1a75e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 11:07:22 +0900 Subject: [PATCH 12/24] test(oidc): cover expired JWKS cache refresh --- test/oidc-jwks-cache-expiry.test.ts | 135 ++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 test/oidc-jwks-cache-expiry.test.ts diff --git a/test/oidc-jwks-cache-expiry.test.ts b/test/oidc-jwks-cache-expiry.test.ts new file mode 100644 index 000000000..8f9df3b66 --- /dev/null +++ b/test/oidc-jwks-cache-expiry.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/index"; + +const configuredWorkflowRef = + "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: configuredWorkflowRef, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_OIDC_JWKS_CACHE_TTL_SECONDS: "1", +}; + +function encodeSegment(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(bytes: ArrayBuffer): string { + return Buffer.from(bytes).toString("base64url"); +} + +async function createSignedJwt(nowEpochSeconds: number) { + 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 = `jwks-cache-expiry-${crypto.randomUUID()}`; + const header = encodeSegment({ alg: "RS256", kid, typ: "JWT" }); + const payload = encodeSegment({ + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: configuredWorkflowRef, + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + exp: nowEpochSeconds + 300, + nbf: nowEpochSeconds - 30, + iat: nowEpochSeconds - 30, + }); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + keyPair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + return { + token: `${header}.${payload}.${encodeBytes(signature)}`, + jwk: { ...publicJwk, kid, kty: "RSA" }, + }; +} + +function exchangeRequest(token: string): Request { + return new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.120", + }, + body: JSON.stringify({ + target_repository: { owner: "ContextualWisdomLab", repo: "noema" }, + }), + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("OIDC JWKS cache expiry", () => { + it("refetches GitHub discovery and JWKS after the configured cache TTL expires", async () => { + vi.resetModules(); + const initialNowMs = Date.now() + 86_400_000; + const dateNow = vi.spyOn(Date, "now").mockReturnValue(initialNowMs); + const { default: worker } = await import("../src/index"); + const { token, jwk } = await createSignedJwt(Math.floor(initialNowMs / 1000)); + const fetchedUrls: string[] = []; + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + fetchedUrls.push(url); + 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] }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + const firstResponse = await worker.fetch(exchangeRequest(token), env); + expect(firstResponse.status).toBe(400); + await expect(firstResponse.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_VALIDATION_INPUT", + details: { field: "target_repository" }, + }); + + dateNow.mockReturnValue(initialNowMs + 2_000); + const secondResponse = await worker.fetch(exchangeRequest(token), env); + expect(secondResponse.status).toBe(400); + await expect(secondResponse.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_VALIDATION_INPUT", + details: { field: "target_repository" }, + }); + + expect( + fetchedUrls.filter( + (url) => url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + ), + ).toHaveLength(2); + expect( + fetchedUrls.filter( + (url) => url === "https://token.actions.githubusercontent.com/.well-known/jwks", + ), + ).toHaveLength(2); + expect(fetchedUrls.every((url) => url.startsWith("https://token.actions.githubusercontent.com/"))).toBe(true); + }); +}); From 511ccb8bc460d376dad2136a854e0fa6ad61f8ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:13:19 +0900 Subject: [PATCH 13/24] test(oidc): expose upstream network failure misclassification --- ...-upstream-network-failure-contract.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 test/oidc-upstream-network-failure-contract.test.ts diff --git a/test/oidc-upstream-network-failure-contract.test.ts b/test/oidc-upstream-network-failure-contract.test.ts new file mode 100644 index 000000000..791857055 --- /dev/null +++ b/test/oidc-upstream-network-failure-contract.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker, { type Env } from "../src/index"; + +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: + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + 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 tokenFor(kid: string): string { + return [ + encodeSegment({ alg: "RS256", kid }), + encodeSegment({}), + Buffer.from("signature").toString("base64url"), + ].join("."); +} + +async function exchange(token: string): Promise { + return worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { authorization: `Bearer ${token}` }, + }), + env, + ); +} + +describe("OIDC upstream network failure classification", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("classifies discovery transport rejection as an upstream 502", async () => { + const kid = `discovery-network-${crypto.randomUUID()}`; + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("network unavailable")); + + const response = await exchange(tokenFor(kid)); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + }); + }); + + it("classifies JWKS transport rejection as an upstream 502", async () => { + const kid = `jwks-network-${crypto.randomUUID()}`; + 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") { + throw new TypeError("network unavailable"); + } + return new Response("unexpected upstream call", { status: 500 }); + }); + + const response = await exchange(tokenFor(kid)); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + }); + }); +}); From 53518e33c0ef73195b8f99eb62ceb1a953f8fea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:15:41 +0900 Subject: [PATCH 14/24] fix(oidc): classify upstream transport failures --- src/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index a0044b87a..52f78b9e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -334,7 +334,12 @@ async function fetchGithubOidcKeys(env: Env, forceRefresh = false): Promise Date: Mon, 17 Aug 2026 12:42:33 +0900 Subject: [PATCH 15/24] test(oidc): cover upstream HTTP status failures --- test/oidc-upstream-failure-contract.test.ts | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/oidc-upstream-failure-contract.test.ts b/test/oidc-upstream-failure-contract.test.ts index 05e0e51e0..f68c20065 100644 --- a/test/oidc-upstream-failure-contract.test.ts +++ b/test/oidc-upstream-failure-contract.test.ts @@ -49,6 +49,28 @@ describe("OIDC upstream document failures", () => { vi.restoreAllMocks(); }); + it("classifies a non-success discovery response as upstream verification failure", async () => { + const fetchedUrls: string[] = []; + const response = await exchangeWith(async (input) => { + const url = String(input); + fetchedUrls.push(url); + if (url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration") { + return new Response("service unavailable", { status: 503 }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "failed to fetch GitHub OIDC discovery document", + }); + expect(fetchedUrls).toEqual([ + "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + ]); + }); + it("classifies malformed discovery JSON as upstream verification failure", async () => { const response = await exchangeWith(async (input) => { const url = String(input); @@ -127,6 +149,32 @@ describe("OIDC upstream document failures", () => { ]); }); + it("classifies a non-success JWKS response as upstream verification failure", async () => { + const fetchedUrls: string[] = []; + const response = await exchangeWith(async (input) => { + const url = String(input); + fetchedUrls.push(url); + 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 new Response("service unavailable", { status: 503 }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "failed to fetch GitHub OIDC JWKS", + }); + expect(fetchedUrls).toEqual([ + "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + "https://token.actions.githubusercontent.com/.well-known/jwks", + ]); + }); + it("classifies malformed JWKS JSON as upstream verification failure", async () => { const response = await exchangeWith(async (input) => { const url = String(input); From 4a5b4d735118c2005c19eb8e0ebd94a95be93c47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 12:48:01 +0900 Subject: [PATCH 16/24] test(oidc): cover null JWKS document boundary --- test/oidc-upstream-failure-contract.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/oidc-upstream-failure-contract.test.ts b/test/oidc-upstream-failure-contract.test.ts index f68c20065..24b223d9d 100644 --- a/test/oidc-upstream-failure-contract.test.ts +++ b/test/oidc-upstream-failure-contract.test.ts @@ -195,6 +195,26 @@ describe("OIDC upstream document failures", () => { }); }); + it("rejects a null JWKS document before key selection", async () => { + const response = await exchangeWith(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(null); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC JWKS did not include a valid keys array", + }); + }); + it("rejects JWKS JSON whose keys member is not an array", async () => { const response = await exchangeWith(async (input) => { const url = String(input); From ecfaecad75dc14794151a7866ccd0143449c59ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 13:08:29 +0900 Subject: [PATCH 17/24] test(oidc): cover residual verification branches --- ...idc-verification-residual-coverage.test.ts | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 test/oidc-verification-residual-coverage.test.ts diff --git a/test/oidc-verification-residual-coverage.test.ts b/test/oidc-verification-residual-coverage.test.ts new file mode 100644 index 000000000..b4dced888 --- /dev/null +++ b/test/oidc-verification-residual-coverage.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/index"; + +const configuredWorkflowRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const trustedDiscoveryUrl = + "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; +const trustedJwksUrl = "https://token.actions.githubusercontent.com/.well-known/jwks"; +const signingKid = "oidc-residual-coverage"; + +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: configuredWorkflowRef, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +}; + +let signingPrivateKey: CryptoKey; +let signingPublicJwk: JsonWebKey; + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(value: Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function baseClaims(now = Math.floor(Date.now() / 1000)): Record { + return { + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: configuredWorkflowRef, + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }; +} + +async function signedJwt( + payload: Record, + header: Record = { alg: "RS256", kid: signingKid }, + corruptSignature = false, +): Promise { + const encodedHeader = encodeJson(header); + const encodedPayload = encodeJson(payload); + const signature = new Uint8Array( + await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + signingPrivateKey, + new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`), + ), + ); + if (corruptSignature) signature[0] ^= 1; + return `${encodedHeader}.${encodedPayload}.${encodeBytes(signature)}`; +} + +async function exchange( + token: string, + fetchImpl?: typeof fetch, + runtimeEnv: Env = env, +): Promise<{ response: Response; fetchedUrls: string[] }> { + vi.resetModules(); + const { default: worker } = await import("../src/index"); + const fetchedUrls: string[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + fetchedUrls.push(url); + if (fetchImpl) return fetchImpl(input, init); + if (url === trustedDiscoveryUrl) return Response.json({ jwks_uri: trustedJwksUrl }); + if (url === trustedJwksUrl) { + return Response.json({ keys: [{ ...signingPublicJwk, kid: signingKid, kty: "RSA" }] }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.121", + }, + body: JSON.stringify({ + target_repository: { owner: "ContextualWisdomLab", repo: "noema" }, + }), + }), + runtimeEnv, + ); + return { response, fetchedUrls }; +} + +beforeAll(async () => { + 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"], + )) as CryptoKeyPair; + signingPrivateKey = keyPair.privateKey; + signingPublicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("OIDC verification residual coverage", () => { + it("accepts an audience array and workflow_ref fallback without nbf", async () => { + const claims = baseClaims(); + claims.aud = ["unrelated-audience", env.ALLOWED_AUDIENCE]; + delete claims.job_workflow_ref; + claims.workflow_ref = configuredWorkflowRef; + delete claims.nbf; + + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_VALIDATION_INPUT", + details: { field: "target_repository" }, + }); + }); + + it("rejects RS256 headers without a kid before OIDC network access", async () => { + const token = await signedJwt(baseClaims(), { alg: "RS256" }); + const { response, fetchedUrls } = await exchange(token); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_TOKEN_MALFORMED", + }); + expect(fetchedUrls).toEqual([]); + }); + + it("rejects matching-kid non-RSA keys after one forced JWKS refresh", async () => { + const token = await signedJwt(baseClaims()); + const { response, fetchedUrls } = await exchange(token, async (input) => { + const url = String(input); + if (url === trustedDiscoveryUrl) return Response.json({ jwks_uri: trustedJwksUrl }); + if (url === trustedJwksUrl) { + return Response.json({ + keys: [{ ...signingPublicJwk, kid: signingKid, kty: "EC" }], + }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "OIDC signing key was not found", + }); + expect(fetchedUrls.filter((url) => url === trustedJwksUrl)).toHaveLength(2); + }); + + it("rejects a token whose signature does not verify", async () => { + const token = await signedJwt(baseClaims(), undefined, true); + const { response } = await exchange(token); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "OIDC signature verification failed", + }); + }); + + it("rejects a future not-before claim after successful signature verification", async () => { + const now = Math.floor(Date.now() / 1000); + const claims = baseClaims(now); + claims.nbf = now + 120; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + message: "OIDC token is not valid yet", + }); + }); + + it("rejects a token without a numeric expiration", async () => { + const claims = baseClaims(); + delete claims.exp; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + message: "OIDC token is expired", + }); + }); + + it("rejects a token with no workflow reference using the empty fallback", async () => { + const claims = baseClaims(); + delete claims.job_workflow_ref; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "OIDC workflow_ref is not allowed", + }); + }); + + it("classifies malformed payload JSON as a malformed token before upstream access", async () => { + const encodedHeader = encodeJson({ alg: "RS256", kid: signingKid }); + const malformedPayload = Buffer.from("{").toString("base64url"); + const token = `${encodedHeader}.${malformedPayload}.AA`; + const { response, fetchedUrls } = await exchange(token); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_TOKEN_MALFORMED", + message: "OIDC token is malformed", + }); + expect(fetchedUrls).toEqual([]); + }); + + it("fails closed when the crypto verifier raises an unexpected runtime error", async () => { + const token = await signedJwt(baseClaims()); + const verifySpy = vi + .spyOn(globalThis.crypto.subtle, "verify") + .mockRejectedValueOnce(new Error("verification backend unavailable")); + + const { response } = await exchange(token); + + expect(verifySpy).toHaveBeenCalledOnce(); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "OIDC token verification failed", + }); + }); + + it("rejects a workflow that passes the configured prefix but belongs to another workflow repository", async () => { + const otherWorkflowRef = + "OtherOrg/central/.github/workflows/noema-review.yml@refs/heads/main"; + const claims = baseClaims(); + claims.job_workflow_ref = otherWorkflowRef; + const runtimeEnv = { + ...env, + ALLOWED_WORKFLOW_REF_PREFIX: otherWorkflowRef, + }; + const { response } = await exchange(await signedJwt(claims), undefined, runtimeEnv); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "OIDC workflow repository is not allowed", + }); + }); +}); From b77ee98b8180e077df2d36626eb19de80187c1b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 13:10:20 +0900 Subject: [PATCH 18/24] test(oidc): reject malformed JWKS key entries --- test/oidc-jwks-key-shape.test.ts | 77 ++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 test/oidc-jwks-key-shape.test.ts diff --git a/test/oidc-jwks-key-shape.test.ts b/test/oidc-jwks-key-shape.test.ts new file mode 100644 index 000000000..4fd4f5b72 --- /dev/null +++ b/test/oidc-jwks-key-shape.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/index"; + +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: + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + 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 structurallyValidJwt(): string { + const now = Math.floor(Date.now() / 1000); + return [ + encodeSegment({ alg: "RS256", kid: "malformed-jwks-key-entry" }), + encodeSegment({ + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }), + "AA", + ].join("."); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("OIDC JWKS key shape", () => { + it("classifies non-object JWKS key entries as an upstream document failure", async () => { + vi.resetModules(); + const { default: worker } = await import("../src/index"); + 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: [null] }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { authorization: `Bearer ${structurallyValidJwt()}` }, + }), + env, + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC JWKS did not include valid key entries", + }); + }); +}); From 378e8fc76e18d7beb635e75bd97a417247d146f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 13:13:51 +0900 Subject: [PATCH 19/24] fix(oidc): validate JWKS key entry shapes --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index 52f78b9e1..5289aceea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -370,6 +370,9 @@ async function fetchGithubOidcKeys(env: Env, forceRefresh = false): Promise Object.prototype.toString.call(key) !== "[object Object]")) { + throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC JWKS did not include valid key entries"); + } oidcKeysCache = { value, expiresAtMs: now + configuredTtlMs(env.NOEMA_OIDC_JWKS_CACHE_TTL_SECONDS, 300, 3600), From 4578475f40fe047f54f496c38f8443d37e4381a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:12:31 +0900 Subject: [PATCH 20/24] test(oidc): reject incomplete RSA JWKS entries upstream --- test/oidc-jwks-key-shape.test.ts | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/oidc-jwks-key-shape.test.ts b/test/oidc-jwks-key-shape.test.ts index 4fd4f5b72..69198ad9f 100644 --- a/test/oidc-jwks-key-shape.test.ts +++ b/test/oidc-jwks-key-shape.test.ts @@ -74,4 +74,38 @@ describe("OIDC JWKS key shape", () => { message: "GitHub OIDC JWKS did not include valid key entries", }); }); + + it("classifies incomplete RSA JWKS entries as an upstream document failure", async () => { + vi.resetModules(); + const { default: worker } = await import("../src/index"); + 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: [{ kid: "malformed-jwks-key-entry", kty: "RSA" }], + }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { authorization: `Bearer ${structurallyValidJwt()}` }, + }), + env, + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_OIDC_VERIFICATION", + message: "GitHub OIDC JWKS did not include valid key entries", + }); + }); }); From 3157107922d67b83f773b2cfa9029ec571e02e9a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:15:44 +0900 Subject: [PATCH 21/24] fix(oidc): classify invalid JWKS key material upstream --- src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 5289aceea..23fe51485 100644 --- a/src/index.ts +++ b/src/index.ts @@ -434,7 +434,10 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise return payload; } catch (error) { if (error instanceof ApiError) throw error; - if (error instanceof SyntaxError || error instanceof TypeError) { + if (error instanceof TypeError) { + throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC JWKS did not include valid key entries"); + } + if (error instanceof SyntaxError) { throw new ApiError("ERR_TOKEN_MALFORMED", 400, "OIDC token is malformed"); } throw new ApiError("ERR_OIDC_VERIFICATION", 401, "OIDC token verification failed"); From 9a8359eaede3a99abf4bc6285f8b68cc597e0c6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 15:05:01 +0900 Subject: [PATCH 22/24] fix(oidc): classify unusable JWKS keys upstream --- src/index.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 23fe51485..17a8309c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -399,13 +399,18 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise } if (!jwk) throw new ApiError("ERR_OIDC_VERIFICATION", 401, "OIDC signing key was not found"); - const key = await crypto.subtle.importKey( - "jwk", - jwk, - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, - false, - ["verify"], - ); + let key: CryptoKey; + try { + key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + } catch { + throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC JWKS did not include valid key entries"); + } const signed = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); const signature = base64UrlDecode(parts[2]); const verified = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, signed); @@ -434,9 +439,6 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise return payload; } catch (error) { if (error instanceof ApiError) throw error; - if (error instanceof TypeError) { - throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC JWKS did not include valid key entries"); - } if (error instanceof SyntaxError) { throw new ApiError("ERR_TOKEN_MALFORMED", 400, "OIDC token is malformed"); } From cdf5018797fb7cfc0a6a3bc09646a9a50d0dd495 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 16:32:04 +0900 Subject: [PATCH 23/24] test(oidc): exercise fresh JWKS cache reuse --- test/oidc-jwks-cache-expiry.test.ts | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/oidc-jwks-cache-expiry.test.ts b/test/oidc-jwks-cache-expiry.test.ts index 8f9df3b66..73692a037 100644 --- a/test/oidc-jwks-cache-expiry.test.ts +++ b/test/oidc-jwks-cache-expiry.test.ts @@ -81,6 +81,51 @@ afterEach(() => { }); describe("OIDC JWKS cache expiry", () => { + it("reuses a fresh cached JWKS without repeating discovery or key-set egress", async () => { + vi.resetModules(); + const fixedNowMs = Date.now() + 86_400_000; + vi.spyOn(Date, "now").mockReturnValue(fixedNowMs); + const { default: worker } = await import("../src/index"); + const { token, jwk } = await createSignedJwt(Math.floor(fixedNowMs / 1000)); + const fetchedUrls: string[] = []; + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + fetchedUrls.push(url); + 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] }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await worker.fetch(exchangeRequest(token), env); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_VALIDATION_INPUT", + details: { field: "target_repository" }, + }); + } + + expect( + fetchedUrls.filter( + (url) => url === "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + ), + ).toHaveLength(1); + expect( + fetchedUrls.filter( + (url) => url === "https://token.actions.githubusercontent.com/.well-known/jwks", + ), + ).toHaveLength(1); + expect(fetchedUrls.every((url) => url.startsWith("https://token.actions.githubusercontent.com/"))).toBe(true); + }); + it("refetches GitHub discovery and JWKS after the configured cache TTL expires", async () => { vi.resetModules(); const initialNowMs = Date.now() + 86_400_000; From 43af2909fb1c87ac2879fce60a4e135907349bef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 16:37:10 +0900 Subject: [PATCH 24/24] test(oidc): cover residual claim rejection branches --- ...idc-verification-residual-coverage.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/oidc-verification-residual-coverage.test.ts b/test/oidc-verification-residual-coverage.test.ts index b4dced888..1bbc427fe 100644 --- a/test/oidc-verification-residual-coverage.test.ts +++ b/test/oidc-verification-residual-coverage.test.ts @@ -149,6 +149,19 @@ describe("OIDC verification residual coverage", () => { expect(fetchedUrls).toEqual([]); }); + it("rejects a non-RS256 header before OIDC network access", async () => { + const token = await signedJwt(baseClaims(), { alg: "HS256", kid: signingKid }); + const { response, fetchedUrls } = await exchange(token); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_TOKEN_MALFORMED", + message: "OIDC token header is not acceptable", + }); + expect(fetchedUrls).toEqual([]); + }); + it("rejects matching-kid non-RSA keys after one forced JWKS refresh", async () => { const token = await signedJwt(baseClaims()); const { response, fetchedUrls } = await exchange(token, async (input) => { @@ -183,6 +196,45 @@ describe("OIDC verification residual coverage", () => { }); }); + it("rejects a token from an untrusted issuer", async () => { + const claims = baseClaims(); + claims.iss = "https://issuer.example.invalid"; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + message: "OIDC issuer is not allowed", + }); + }); + + it("rejects a token whose audience does not include Noema", async () => { + const claims = baseClaims(); + claims.aud = "different-audience"; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + message: "OIDC audience is not allowed", + }); + }); + + it("rejects a token from a different repository owner", async () => { + const claims = baseClaims(); + claims.repository_owner = "OtherOwner"; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_REPO_NOT_ALLOWED", + message: "OIDC repository owner is not allowed", + }); + }); + it("rejects a future not-before claim after successful signature verification", async () => { const now = Math.floor(Date.now() / 1000); const claims = baseClaims(now); @@ -210,6 +262,20 @@ describe("OIDC verification residual coverage", () => { }); }); + it("rejects a numerically expired token", async () => { + const now = Math.floor(Date.now() / 1000); + const claims = baseClaims(now); + claims.exp = now - 120; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_INVALID", + message: "OIDC token is expired", + }); + }); + it("rejects a token with no workflow reference using the empty fallback", async () => { const claims = baseClaims(); delete claims.job_workflow_ref; @@ -223,6 +289,20 @@ describe("OIDC verification residual coverage", () => { }); }); + it("rejects a workflow reference outside the configured prefix", async () => { + const claims = baseClaims(); + claims.job_workflow_ref = + "ContextualWisdomLab/.github/.github/workflows/another.yml@refs/heads/main"; + const { response } = await exchange(await signedJwt(claims)); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "OIDC workflow_ref is not allowed", + }); + }); + it("classifies malformed payload JSON as a malformed token before upstream access", async () => { const encodedHeader = encodeJson({ alg: "RS256", kid: signingKid }); const malformedPayload = Buffer.from("{").toString("base64url");