Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 5 additions & 3 deletions src/github/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,11 @@ async function mintInstallationToken(
throw new Error(
"GitHub installation token response did not include a token.",
);
const expiresAtMs = payload.expires_at
? Date.parse(payload.expires_at)
: Date.now() + 50 * 60_000;
// A present-but-UNPARSEABLE expires_at must fall back like an absent one (#10026): Date.parse returns NaN for
// a malformed string, and a NaN expiry makes `expiresAtMs - MARGIN > Date.now()` forever false — silently
// disabling the cache so every job re-mints and re-triggers the thundering-herd this cache exists to prevent.
const parsedExpiry = payload.expires_at ? Date.parse(payload.expires_at) : Number.NaN;
const expiresAtMs = Number.isFinite(parsedExpiry) ? parsedExpiry : Date.now() + 50 * 60_000;
await writeCachedToken(installationId, { token: payload.token, expiresAtMs });
return payload.token;
}
Expand Down
41 changes: 41 additions & 0 deletions test/unit/github-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,47 @@ describe("GitHub check runs", () => {
expect(mints).toBe(1);
});

it("REGRESSION: an unparseable expires_at must not disable the installation-token cache (#10026)", async () => {
const privateKey = await generatePrivateKeyPem();
let mints = 0;
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("/access_tokens")) {
mints += 1;
return Response.json({ token: `installation-token-${mints}`, expires_at: "not-a-date" }); // present but unparseable
}
return new Response("not found", { status: 404 });
});

const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey });
const first = await createInstallationToken(env, 777);
const second = await createInstallationToken(env, 777);
// The NaN expiry used to make every cache comparison false, re-minting on every call. With the finite
// fallback the entry is honored: exactly one mint, the same token reused.
expect(first).toBe("installation-token-1");
expect(second).toBe("installation-token-1");
expect(mints).toBe(1); // NOT re-minted
});

it("a well-formed expires_at is unchanged: token reused on the second call (#10026)", async () => {
const privateKey = await generatePrivateKeyPem();
let mints = 0;
const expiresAt = "2030-01-01T00:00:00Z";
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("/access_tokens")) {
mints += 1;
return Response.json({ token: `installation-token-${mints}`, expires_at: expiresAt });
}
return new Response("not found", { status: 404 });
});

const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey });
expect(await createInstallationToken(env, 888)).toBe("installation-token-1");
expect(await createInstallationToken(env, 888)).toBe("installation-token-1");
expect(mints).toBe(1); // cached against the far-future, well-formed expiry
});

it("(#3811) samples clock skew from the installation-token mint response's Date header", async () => {
const privateKey = await generatePrivateKeyPem();
vi.useFakeTimers();
Expand Down