From 9d9b48d8cee0aba10db7ceb678ea1cc6ed28df4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:38:49 +0900 Subject: [PATCH 1/2] test(exchange): prove a ~520-char stateless installation token round-trips GitHub announced that GitHub App installation tokens are moving to a new stateless ghs_... format that can be roughly 520 characters, up from the historical ~40. Audited createInstallationToken() and confirmed githubInstallationTokenPattern (src/index.ts) already validates with /^[\x21-\x7e]{1,4096}$/ -- a printable-ASCII, 1-4096-length pattern with no fixed-length assumption -- so no source change is required. This adds a regression test locking in that a ~520-character token exchanges successfully end-to-end, since no existing test exercised a token longer than the historical format. Co-Authored-By: Claude Sonnet 5 --- ...nstallation-token-stateless-format.test.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 test/github-installation-token-stateless-format.test.ts 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..a7e084536 --- /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 announced that installation tokens are moving to a new stateless + * `ghs_...` format that can be roughly 520 characters, up from the historical + * ~40. `createInstallationToken` in `src/index.ts` validates the minted + * token against `githubInstallationTokenPattern`, a printable-ASCII pattern + * bounded only by an overall 1-4096 length ceiling -- it never assumes a + * fixed or ~40-character length. This test proves that a long stateless + * token round-trips through `/exchange` unchanged rather than being rejected + * or truncated. + */ + +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"; + +/** A ~520-character token matching GitHub's new stateless `ghs_` format. */ +const statelessFormatToken = `ghs_${"A".repeat(516)}`; + +/** 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("passes a ~520-character stateless-format token through /exchange unchanged", async () => { + expect(statelessFormatToken.length).toBe(520); + + 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, + }, + }); + }); +}); From 009187d6e70e5aec2f2c47630a87590cc98bcb55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 23:13:18 +0900 Subject: [PATCH 2/2] test(github): model stateless token separators --- ...nstallation-token-stateless-format.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/github-installation-token-stateless-format.test.ts b/test/github-installation-token-stateless-format.test.ts index a7e084536..5662ef471 100644 --- a/test/github-installation-token-stateless-format.test.ts +++ b/test/github-installation-token-stateless-format.test.ts @@ -2,14 +2,12 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import worker, { type Env } from "../src/index"; /** - * GitHub announced that installation tokens are moving to a new stateless - * `ghs_...` format that can be roughly 520 characters, up from the historical - * ~40. `createInstallationToken` in `src/index.ts` validates the minted - * token against `githubInstallationTokenPattern`, a printable-ASCII pattern - * bounded only by an overall 1-4096 length ceiling -- it never assumes a - * fixed or ~40-character length. This test proves that a long stateless - * token round-trips through `/exchange` unchanged rather than being rejected - * or truncated. + * 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 = @@ -19,8 +17,9 @@ const targetRepository = "ContextualWisdomLab/installation-token-stateless-forma const installationId = "93001"; const signingKid = "installation-token-stateless-format"; -/** A ~520-character token matching GitHub's new stateless `ghs_` format. */ -const statelessFormatToken = `ghs_${"A".repeat(516)}`; +/** 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 { @@ -142,8 +141,9 @@ afterEach(() => { }); describe("GitHub installation-token stateless format", () => { - it("passes a ~520-character stateless-format token through /exchange unchanged", async () => { + 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);