diff --git a/test/github-installation-token-stateless-format.test.ts b/test/github-installation-token-stateless-format.test.ts new file mode 100644 index 000000000..5662ef471 --- /dev/null +++ b/test/github-installation-token-stateless-format.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import worker, { type Env } from "../src/index"; + +/** + * GitHub's stateless installation tokens retain the `ghs_` prefix, are around + * 520 characters, and carry a JWT-shaped suffix with two dot separators. + * Clients are expected to treat that suffix as opaque rather than decoding or + * validating its claims. `createInstallationToken` in `src/index.ts` already + * accepts printable ASCII up to 4096 characters, so this regression exercises + * the changed transport shape without adding a second token parser. + */ + +const configuredWorkflowRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); +const targetRepository = "ContextualWisdomLab/installation-token-stateless-format"; +const installationId = "93001"; +const signingKid = "installation-token-stateless-format"; + +/** Representative 520-character `ghs_APPID_JWT` value, intentionally opaque to Noema. */ +const statelessFormatToken = + `ghs_12345_${"A".repeat(80)}.${"B".repeat(300)}.${"C".repeat(128)}`; + +/** Return a replay-guard namespace that accepts every claim, as a real first-use would. */ +function acceptingReplayGuard(): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return { + fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")); + return Response.json( + { accepted: true, expires_at_epoch_seconds: body.expires_at_epoch_seconds }, + { status: 201 }, + ); + }, + } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +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, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "initialized-in-beforeAll", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_OIDC_REPLAY_GUARD: acceptingReplayGuard(), +}; + +let oidcKeyPair: CryptoKeyPair; +let oidcPublicJwk: JsonWebKey; +let appPrivateKeyPem: string; + +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 generateRsaKeyPair(): Promise { + return crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); +} + +async function signedOidcToken(): Promise { + const now = Math.floor(Date.now() / 1000); + const header = encodeSegment({ alg: "RS256", kid: signingKid, typ: "JWT" }); + const payload = encodeSegment({ + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository_owner_id: "295022177", + repository: "ContextualWisdomLab/.github", + repository_id: "1274066402", + job_workflow_ref: configuredWorkflowRef, + job_workflow_sha: configuredWorkflowSha, + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + jti: crypto.randomUUID(), + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + oidcKeyPair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + return `${header}.${payload}.${encodeBytes(signature)}`; +} + +async function exchange(clientIp: string): Promise { + return worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${await signedOidcToken()}`, + "content-type": "application/json", + "cf-connecting-ip": clientIp, + }, + body: JSON.stringify({ target_repository: targetRepository }), + }), + { ...env, GITHUB_APP_PRIVATE_KEY_PEM: appPrivateKeyPem }, + ); +} + +beforeAll(async () => { + oidcKeyPair = await generateRsaKeyPair(); + oidcPublicJwk = await crypto.subtle.exportKey("jwk", oidcKeyPair.publicKey); + const appKeyPair = await generateRsaKeyPair(); + appPrivateKeyPem = pemFromPkcs8( + await crypto.subtle.exportKey("pkcs8", appKeyPair.privateKey), + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("GitHub installation-token stateless format", () => { + it("round-trips a representative 520-character stateless token without inspecting it", async () => { + expect(statelessFormatToken.length).toBe(520); + expect(statelessFormatToken.match(/\./g)).toHaveLength(2); + + 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: [{ ...oidcPublicJwk, kid: signingKid, kty: "RSA" }], + }); + } + if (url === `https://api.github.com/repos/${targetRepository}/installation`) { + return Response.json({ id: Number(installationId) }); + } + if (url === `https://api.github.com/app/installations/${installationId}/access_tokens`) { + return Response.json({ + token: statelessFormatToken, + expires_at: new Date(Date.now() + 60 * 60_000).toISOString(), + }, { status: 201 }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + const response = await exchange("203.0.113.220"); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload).toMatchObject({ + ok: true, + data: { + repository: targetRepository, + token: statelessFormatToken, + }, + }); + }); +});