Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8998453
test(coverage): require OIDC core instrumentation
seonghobae Aug 16, 2026
e66c880
fix(coverage): measure OIDC verification core
seonghobae Aug 16, 2026
4c77e15
test(coverage): preserve canonical index newline
seonghobae Aug 16, 2026
da6af5b
fix(coverage): restore canonical index newline
seonghobae Aug 16, 2026
a8cab93
test(oidc): distinguish malformed upstream documents
seonghobae Aug 16, 2026
5260b95
fix(oidc): classify malformed upstream JSON correctly
seonghobae Aug 16, 2026
bb97c29
test(oidc): fail closed on malformed upstream schemas
seonghobae Aug 16, 2026
81b5e72
fix(oidc): validate upstream discovery and JWKS schemas
seonghobae Aug 16, 2026
39942da
test(security): reject off-origin OIDC JWKS discovery
seonghobae Aug 16, 2026
cd22d83
fix(security): pin OIDC JWKS to trusted GitHub origin
seonghobae Aug 16, 2026
09ef1a1
merge(main): integrate acquisition hardening into OIDC coverage slice
seonghobae Aug 17, 2026
82114ce
test(oidc): cover empty JWKS discovery URI
seonghobae Aug 17, 2026
a311051
test(oidc): cover expired JWKS cache refresh
seonghobae Aug 17, 2026
511ccb8
test(oidc): expose upstream network failure misclassification
seonghobae Aug 17, 2026
53518e3
fix(oidc): classify upstream transport failures
seonghobae Aug 17, 2026
338c6ff
test(oidc): cover upstream HTTP status failures
seonghobae Aug 17, 2026
4a5b4d7
test(oidc): cover null JWKS document boundary
seonghobae Aug 17, 2026
ecfaeca
test(oidc): cover residual verification branches
seonghobae Aug 17, 2026
b77ee98
test(oidc): reject malformed JWKS key entries
seonghobae Aug 17, 2026
378e8fc
fix(oidc): validate JWKS key entry shapes
seonghobae Aug 17, 2026
4578475
test(oidc): reject incomplete RSA JWKS entries upstream
seonghobae Aug 17, 2026
3157107
fix(oidc): classify invalid JWKS key material upstream
seonghobae Aug 17, 2026
9a8359e
fix(oidc): classify unusable JWKS keys upstream
seonghobae Aug 17, 2026
cdf5018
test(oidc): exercise fresh JWKS cache reuse
seonghobae Aug 17, 2026
43af290
test(oidc): cover residual claim rejection branches
seonghobae Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 50 additions & 15 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,6 @@ function logRequest({
console.log(JSON.stringify(payload));
}

/* v8 ignore start */
function base64UrlDecode(input: string): Uint8Array<ArrayBuffer> {
const padded = input.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((input.length + 3) % 4);
const binary = atob(padded);
Expand All @@ -335,13 +334,45 @@ async function fetchGithubOidcKeys(env: Env, forceRefresh = false): Promise<Json
return oidcKeysCache.value;
}

const discovery = await fetch("https://token.actions.githubusercontent.com/.well-known/openid-configuration");
let discovery: Response;
try {
discovery = await fetch("https://token.actions.githubusercontent.com/.well-known/openid-configuration");
} catch {
throw new ApiError("ERR_OIDC_VERIFICATION", 502, "failed to fetch GitHub OIDC discovery document");
}
if (!discovery.ok) throw new ApiError("ERR_OIDC_VERIFICATION", 502, "failed to fetch GitHub OIDC discovery document");
const { jwks_uri: jwksUri } = (await discovery.json()) as { jwks_uri?: string };
if (!jwksUri) throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC discovery document did not include jwks_uri");
const keys = await fetch(jwksUri);
let discoveryDocument: { jwks_uri?: unknown };
try {
discoveryDocument = (await discovery.json()) as { jwks_uri?: unknown };
} catch {
throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC discovery document was not valid JSON");
}
const jwksUri = discoveryDocument.jwks_uri;
if (typeof jwksUri !== "string" || jwksUri.length === 0) {
throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC discovery document did not include a valid jwks_uri");
}
if (jwksUri !== "https://token.actions.githubusercontent.com/.well-known/jwks") {
throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC discovery document included an untrusted jwks_uri");
}
let keys: Response;
try {
keys = await fetch(jwksUri);
} catch {
throw new ApiError("ERR_OIDC_VERIFICATION", 502, "failed to fetch GitHub OIDC JWKS");
}
if (!keys.ok) throw new ApiError("ERR_OIDC_VERIFICATION", 502, "failed to fetch GitHub OIDC JWKS");
const value = (await keys.json()) as JsonWebKeySet;
let value: JsonWebKeySet;
try {
value = (await keys.json()) as JsonWebKeySet;
} catch {
throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC JWKS was not valid JSON");
}
if (!Array.isArray(value?.keys)) {
throw new ApiError("ERR_OIDC_VERIFICATION", 502, "GitHub OIDC JWKS did not include a valid keys array");
}
if (value.keys.some((key) => 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),
Expand All @@ -368,13 +399,18 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise<JwtPayload>
}
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);
Expand Down Expand Up @@ -403,13 +439,12 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise<JwtPayload>
return payload;
} catch (error) {
if (error instanceof ApiError) throw error;
if (error instanceof SyntaxError || error instanceof TypeError) {
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");
}
}
/* v8 ignore stop */

function validateRepositoryName(repository: string, env: Env): string {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
Expand Down
9 changes: 9 additions & 0 deletions test/coverage-ignore-operational-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -29,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);
});
});
180 changes: 180 additions & 0 deletions test/oidc-jwks-cache-expiry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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("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;
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);
});
});
Loading
Loading