diff --git a/.dev.vars.example b/.dev.vars.example index 063a60b9..d5a4b14a 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -14,6 +14,12 @@ GITHUB_TOKEN= # Public Artifact builds read GitHub with this token when it is set. Use a token # from another GitHub account, so runs keep a quota the registry sync cannot spend. ARTIFACT_GITHUB_TOKEN= +# Public Artifact builds read GitHub as the read App: metadata read only, +# installed on skilld-dev. Its installation has a quota nothing else spends. +# The key is a PKCS #8 PEM. Without these, builds use the tokens above. +SKILLD_READ_APP_ID= +SKILLD_READ_APP_INSTALLATION_ID= +SKILLD_READ_APP_PRIVATE_KEY_PKCS8= ARTIFACT_PRIVATE_ACCESS_ENABLED=false GITHUB_APP_ID= GITHUB_APP_CLIENT_ID= diff --git a/docs/ops/artifact-delivery-rollout.md b/docs/ops/artifact-delivery-rollout.md index 3a1214bb..16e2ad09 100644 --- a/docs/ops/artifact-delivery-rollout.md +++ b/docs/ops/artifact-delivery-rollout.md @@ -113,7 +113,16 @@ Preserve these existing values: - `NUXT_TOKEN_KEY` - `GITHUB_TOKEN` -The existing `GITHUB_TOKEN` reads public [GitHub](https://github.com) sources. +The existing `GITHUB_TOKEN` reads public [GitHub](https://github.com) sources for the registry sync and `/gh` pages. + +Public Artifact builds read GitHub with a credential of their own, in this order: + +1. The read App `skilld-dev-registry-reads`: `SKILLD_READ_APP_ID`, `SKILLD_READ_APP_INSTALLATION_ID` and `SKILLD_READ_APP_PRIVATE_KEY_PKCS8` (a PKCS #8 PEM). It has metadata read permission only, and its installation has its own quota. +2. `ARTIFACT_GITHUB_TOKEN`. +3. `GITHUB_TOKEN`. + +If the App secrets are set but unusable, builds read with the next credential and emit an `artifact-github-credential` event with the reason. +Keep the read App apart from the `GITHUB_APP_*` secrets below. Those belong to private delivery. Changing `NUXT_TOKEN_KEY` breaks stored GitHub credentials. diff --git a/layers/artifact-delivery/server/utils/github-app.ts b/layers/artifact-delivery/server/utils/github-app.ts index 6fdfd17f..f6a7d541 100644 --- a/layers/artifact-delivery/server/utils/github-app.ts +++ b/layers/artifact-delivery/server/utils/github-app.ts @@ -491,22 +491,16 @@ export function createGithubAppClient(config: GithubAppConfig): GithubAppClient }, async createRepositoryToken(installationId, repositoryId) { - const jwt = await createGithubAppJwt(config) - const result = await request( - `/app/installations/${installationId}/access_tokens`, - jwt, - installationTokenSchema, - { - method: 'POST', - body: { - repository_ids: [repositoryId], - permissions: { contents: 'read', metadata: 'read' }, - }, + const result = await requestInstallationToken({ + fetch: config.fetch, + jwt: await createGithubAppJwt(config), + installationId, + body: { + repository_ids: [repositoryId], + permissions: { contents: 'read', metadata: 'read' }, }, - ) - return result._tag === 'ok' - ? { _tag: 'created', token: result.value.token, expiresAt: result.value.expires_at } - : result + }) + return result._tag === 'created' ? result : { _tag: 'not-found' } }, async userCanAccessRepository(userToken, installationId, repositoryId) { @@ -547,21 +541,81 @@ export async function verifyGithubWebhookSignature( return different === 0 } +export type InstallationTokenResult + = | { _tag: 'created', token: string, expiresAt: string } + /** GitHub answered 401, 403 or 404: the App, its key or the installation is gone. */ + | { _tag: 'refused', status: number } + +/** + * Exchange an App JWT for an installation access token. + * + * A refusal is a value. Any other failure, such as a 5xx or an unreadable + * answer, throws: it says nothing about the App. + */ +export async function requestInstallationToken(input: { + fetch: typeof globalThis.fetch + jwt: string + installationId: number + body: { repository_ids?: number[], permissions: Record } +}): Promise { + const fetched = await fetchNoRedirect(input.fetch, `${GITHUB_API}/app/installations/${input.installationId}/access_tokens`, { + method: 'POST', + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${input.jwt}`, + 'Content-Type': 'application/json', + 'User-Agent': 'skilld.dev', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }, + body: JSON.stringify(input.body), + signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS), + }) + if (fetched._tag === 'unexpected-redirect') + throw new Error(`GitHub App request redirected ${fetched.status}`) + const response = fetched.response + if (response.status === 401 || response.status === 403 || response.status === 404) { + await response.body?.cancel() + return { _tag: 'refused', status: response.status } + } + if (!response.ok) + throw new Error(`GitHub App request returned ${response.status}`) + const value = installationTokenSchema.safeParse(await readBoundedJson(response, MAX_GITHUB_TOKEN_RESPONSE_BYTES)) + if (!value.success) + throw new Error('GitHub App returned an invalid response') + return { _tag: 'created', token: value.data.token, expiresAt: value.data.expires_at } +} + async function createGithubAppJwt(config: GithubAppConfig): Promise { const privateKey = decodeCanonicalBase64Url(config.privateKeyPkcs8) if (!privateKey) throw new Error('GitHub App private key is malformed') - const issuedAt = config.now() - 60 + return await signGithubAppJwt({ issuer: config.clientId, privateKeyPkcs8: privateKey, now: config.now() }) +} + +/** + * An RS256 GitHub App JWT. `issuer` is the App's client ID or its App ID; + * GitHub accepts either. + * + * It is valid for ten minutes from a minute in the past, which absorbs clock + * drift between the Worker and GitHub. Ten minutes is GitHub's maximum. + */ +export async function signGithubAppJwt(input: { + issuer: string + privateKeyPkcs8: Uint8Array + /** Unix seconds. */ + now: number +}): Promise { + const issuedAt = input.now - 60 const header = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))) const payload = bytesToBase64Url(new TextEncoder().encode(JSON.stringify({ iat: issuedAt, exp: issuedAt + 600, - iss: config.clientId, + iss: input.issuer, }))) const unsigned = `${header}.${payload}` const imported = await crypto.subtle.importKey( 'pkcs8', - toArrayBuffer(privateKey), + toArrayBuffer(input.privateKeyPkcs8), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'], diff --git a/layers/artifact-delivery/server/utils/github-read-credential.ts b/layers/artifact-delivery/server/utils/github-read-credential.ts new file mode 100644 index 00000000..6398b471 --- /dev/null +++ b/layers/artifact-delivery/server/utils/github-read-credential.ts @@ -0,0 +1,214 @@ +import type { PublicGithubSourceClient } from './github-source' +import { requestInstallationToken, signGithubAppJwt } from './github-app' + +/** + * Renew an installation token this long before GitHub says it expires, so a + * build that starts with it never sees it lapse part way through. + */ +export const INSTALLATION_TOKEN_RENEW_SECONDS = 5 * 60 + +/** The read App: metadata read only, installed on skilld-dev. */ +export interface ReadAppConfig { + appId: number + installationId: number + privateKeyPkcs8: Uint8Array +} + +/** The token a build reads with when the App gives none, by its secret name. */ +export interface FallbackToken { + name: 'ARTIFACT_GITHUB_TOKEN' | 'GITHUB_TOKEN' + token: string +} + +/** + * Where public Artifact builds get their GitHub credential, parsed once from + * the Worker env. + * + * GitHub counts a personal token's quota per account, so every token of one + * account shares one bucket. An App installation has a bucket of its own. + */ +export type ArtifactGithubCredentialConfig + = | { _tag: 'app', app: ReadAppConfig, fallback: FallbackToken | null } + /** App secrets are set but unusable. Builds read with the fallback and report why. */ + | { _tag: 'app-misconfigured', reason: string, fallback: FallbackToken | null } + | { _tag: 'token', token: FallbackToken } + | { _tag: 'anonymous' } + +export interface ArtifactGithubCredentialEnv { + SKILLD_READ_APP_ID?: string + SKILLD_READ_APP_INSTALLATION_ID?: string + SKILLD_READ_APP_PRIVATE_KEY_PKCS8?: string + ARTIFACT_GITHUB_TOKEN?: string + GITHUB_TOKEN?: string +} + +/** Why a build read with a fallback token. It names secrets, never a value. */ +export interface ArtifactGithubCredentialReport { + outcome: 'app-misconfigured' | 'app-token-unavailable' + reason: string + fallback: FallbackToken['name'] | 'anonymous' +} + +interface CachedInstallationToken { + token: string + /** Unix seconds. */ + expiresAt: number +} + +/** + * Installation tokens kept between builds in one isolate. A token lives an + * hour; minting one per build would add a request and a signature to each. + */ +export interface InstallationTokenCache { + tokens: Map + pending: Map> +} + +export function createInstallationTokenCache(): InstallationTokenCache { + return { tokens: new Map(), pending: new Map() } +} + +export interface ArtifactGithubCredentialRuntime { + fetch: typeof globalThis.fetch + /** Unix seconds. */ + now: () => number + tokenCache: InstallationTokenCache + report: (event: ArtifactGithubCredentialReport) => void +} + +/** The credential for one GitHub read. Undefined reads anonymously. */ +export type ArtifactGithubCredential = () => Promise + +export function parseArtifactGithubCredentialConfig(env: ArtifactGithubCredentialEnv): ArtifactGithubCredentialConfig { + const fallback = fallbackToken(env) + const appSecrets = [env.SKILLD_READ_APP_ID, env.SKILLD_READ_APP_INSTALLATION_ID, env.SKILLD_READ_APP_PRIVATE_KEY_PKCS8] + if (appSecrets.every(value => !value?.trim())) + return fallback ? { _tag: 'token', token: fallback } : { _tag: 'anonymous' } + const misconfigured = (reason: string): ArtifactGithubCredentialConfig => ({ _tag: 'app-misconfigured', reason, fallback }) + const appId = positiveInteger(env.SKILLD_READ_APP_ID) + if (appId === null) + return misconfigured('SKILLD_READ_APP_ID is not a GitHub App ID') + const installationId = positiveInteger(env.SKILLD_READ_APP_INSTALLATION_ID) + if (installationId === null) + return misconfigured('SKILLD_READ_APP_INSTALLATION_ID is not an installation ID') + const privateKeyPkcs8 = pkcs8PemBytes(env.SKILLD_READ_APP_PRIVATE_KEY_PKCS8 ?? '') + if (!privateKeyPkcs8) + return misconfigured('SKILLD_READ_APP_PRIVATE_KEY_PKCS8 is not a PKCS #8 PEM private key') + return { _tag: 'app', app: { appId, installationId, privateKeyPkcs8 }, fallback } +} + +/** + * The GitHub credential public Artifact builds read with: the read App's + * installation token, then `ARTIFACT_GITHUB_TOKEN`, then `GITHUB_TOKEN`. + * + * The registry sync and `/gh` page views keep `GITHUB_TOKEN`. When the App + * cannot give a token, the build still runs on the fallback and the reason is + * reported, so a broken key shows up as an event instead of as a spent quota. + */ +export function createArtifactGithubCredential( + config: ArtifactGithubCredentialConfig, + runtime: ArtifactGithubCredentialRuntime, +): ArtifactGithubCredential { + if (config._tag === 'anonymous') + return async () => undefined + if (config._tag === 'token') + return async () => config.token.token + const fallbackName = config.fallback?.name ?? 'anonymous' + if (config._tag === 'app-misconfigured') { + return async () => { + runtime.report({ outcome: 'app-misconfigured', reason: config.reason, fallback: fallbackName }) + return config.fallback?.token + } + } + const app = config.app + const key = `${app.appId}:${app.installationId}` + return async () => { + const cached = runtime.tokenCache.tokens.get(key) + if (cached && cached.expiresAt - INSTALLATION_TOKEN_RENEW_SECONDS > runtime.now()) + return cached.token + let pending = runtime.tokenCache.pending.get(key) + if (!pending) { + pending = mintInstallationToken(app, runtime).finally(() => runtime.tokenCache.pending.delete(key)) + runtime.tokenCache.pending.set(key, pending) + } + const minted = await pending + if ('token' in minted) { + runtime.tokenCache.tokens.set(key, minted) + return minted.token + } + runtime.report({ outcome: 'app-token-unavailable', reason: minted.reason, fallback: fallbackName }) + return config.fallback?.token + } +} + +/** + * A GitHub source client whose every read asks for the current credential, so + * a long-lived client never reads with an expired installation token. + */ +export function withGithubCredential( + credential: ArtifactGithubCredential, + create: (token: string | undefined) => PublicGithubSourceClient, +): PublicGithubSourceClient { + return { + resolve: async request => await create(await credential()).resolve(request), + load: async source => await create(await credential()).load(source), + } +} + +async function mintInstallationToken( + app: ReadAppConfig, + runtime: Pick, +): Promise { + const minted = await signGithubAppJwt({ issuer: String(app.appId), privateKeyPkcs8: app.privateKeyPkcs8, now: runtime.now() }) + .then(jwt => requestInstallationToken({ + fetch: runtime.fetch, + jwt, + installationId: app.installationId, + body: { permissions: { metadata: 'read' } }, + })) + // A network fault, a 5xx or a key WebCrypto rejects: the build reads with + // the fallback token, and the reason reaches the report. + .catch((error: unknown) => ({ _tag: 'failed' as const, reason: error instanceof Error ? error.message : String(error) })) + if (minted._tag === 'failed') + return { _tag: 'unavailable', reason: minted.reason.slice(0, 200) } + if (minted._tag === 'refused') + return { _tag: 'unavailable', reason: `GitHub refused the installation token (${minted.status})` } + const expiresAt = Math.floor(Date.parse(minted.expiresAt) / 1000) + return { token: minted.token, expiresAt } +} + +function fallbackToken(env: ArtifactGithubCredentialEnv): FallbackToken | null { + if (env.ARTIFACT_GITHUB_TOKEN?.trim()) + return { name: 'ARTIFACT_GITHUB_TOKEN', token: env.ARTIFACT_GITHUB_TOKEN.trim() } + if (env.GITHUB_TOKEN?.trim()) + return { name: 'GITHUB_TOKEN', token: env.GITHUB_TOKEN.trim() } + return null +} + +function positiveInteger(value: string | undefined): number | null { + const trimmed = value?.trim() ?? '' + if (!/^[1-9]\d*$/.test(trimmed)) + return null + const parsed = Number(trimmed) + return Number.isSafeInteger(parsed) ? parsed : null +} + +/** + * The DER bytes of a PKCS #8 PEM private key, or null. + * + * A secret pasted through a dashboard or a JSON file can hold its line breaks + * as the two characters `\n`, so both forms parse. A PKCS #1 key + * (`BEGIN RSA PRIVATE KEY`, as GitHub downloads it) does not: WebCrypto + * imports PKCS #8 only. Convert it with `openssl pkcs8 -topk8 -nocrypt`. + */ +function pkcs8PemBytes(value: string): Uint8Array | null { + const text = value.replaceAll('\\n', '\n').trim() + const match = /^-----BEGIN PRIVATE KEY-----([A-Z0-9+/=\s]+)-----END PRIVATE KEY-----$/i.exec(text) + if (!match) + return null + const base64 = match[1]!.replace(/\s+/g, '') + if (!/^[A-Z0-9+/]+={0,2}$/i.test(base64) || base64.length % 4 !== 0) + return null + const binary = atob(base64) + return Uint8Array.from(binary, character => character.charCodeAt(0)) +} diff --git a/layers/artifact-delivery/server/utils/queue.ts b/layers/artifact-delivery/server/utils/queue.ts index 2fe4417f..cfb0badd 100644 --- a/layers/artifact-delivery/server/utils/queue.ts +++ b/layers/artifact-delivery/server/utils/queue.ts @@ -1,6 +1,11 @@ import type { QueueBatch } from '#cf-jobs/server' import type { ArtifactBuildDependencies } from './build' -import type { GithubReadFailure } from './github-source' +import type { + ArtifactGithubCredentialEnv, + ArtifactGithubCredentialReport, + ArtifactGithubCredentialRuntime, +} from './github-read-credential' +import type { GithubReadFailure, PublicGithubSourceClient } from './github-source' import { createWideEvent } from '@harlan-zw/nuxt-wide-events/standalone' import { z } from 'zod' import { emitOperationalEvent } from '#server/utils/operational-event' @@ -11,6 +16,12 @@ import { githubAppUserTokenDependenciesFromEnv, loadAccountGithubAppUserToken, } from './github-app' +import { + createArtifactGithubCredential, + createInstallationTokenCache, + parseArtifactGithubCredentialConfig, + withGithubCredential, +} from './github-read-credential' import { createGithubSourceClient, createPublicGithubSourceClient } from './github-source' import { createD1PrivateArtifactKeyProvider, privateArtifactWrappingKeysFromEnv } from './private-crypto' import { privateArtifactAccessEnabled } from './private-feature' @@ -99,16 +110,47 @@ export function reportGithubReadFailure(failure: GithubReadFailure): void { } /** - * The token public Artifact builds read GitHub with. + * One installation token cache per isolate. A token lives an hour, so it + * outlives the queue batch that minted it. + */ +const installationTokens = createInstallationTokenCache() + +/** + * The GitHub source public Artifact builds read with. * * GitHub counts a personal token's quota per account, and the registry sync - * spends `GITHUB_TOKEN` to zero before each hourly reset. Every run that needed - * GitHub in those minutes failed RATE_LIMITED. `ARTIFACT_GITHUB_TOKEN`, from - * another account, keeps a quota for runs. Until it is set, builds share - * `GITHUB_TOKEN`. + * and `/gh` page views spend `GITHUB_TOKEN` to zero before each hourly + * reset. Every run that needed GitHub in those minutes failed RATE_LIMITED. + * Builds read with the read App's installation token, whose bucket nothing + * else spends, then `ARTIFACT_GITHUB_TOKEN`, then `GITHUB_TOKEN`. */ -export function artifactGithubToken(env: Partial>): string | undefined { - return env.ARTIFACT_GITHUB_TOKEN || env.GITHUB_TOKEN +export function createArtifactGithubSource( + env: ArtifactGithubCredentialEnv, + runtime: ArtifactGithubCredentialRuntime = defaultGithubSourceRuntime(), +): PublicGithubSourceClient { + const credential = createArtifactGithubCredential(parseArtifactGithubCredentialConfig(env), runtime) + return withGithubCredential( + credential, + token => createPublicGithubSourceClient({ fetch: runtime.fetch, token, onReadFailure: reportGithubReadFailure }), + ) +} + +function defaultGithubSourceRuntime(): ArtifactGithubCredentialRuntime { + return { + fetch: globalThis.fetch.bind(globalThis), + now: () => Math.floor(Date.now() / 1000), + tokenCache: installationTokens, + report: reportGithubCredential, + } +} + +function reportGithubCredential(event: ArtifactGithubCredentialReport): void { + emitOperationalEvent(createWideEvent({ + 'operation': 'artifact-github-credential', + 'outcome': event.outcome, + 'reason': event.reason, + 'github.credential': event.fallback, + })) } export function createArtifactBuildDependencies(env: Cloudflare.Env): ArtifactBuildDependencies { @@ -118,7 +160,7 @@ export function createArtifactBuildDependencies(env: Cloudflare.Env): ArtifactBu : {} return { db: env.DB, - github: createPublicGithubSourceClient({ fetch: runtimeFetch, token: artifactGithubToken(env), onReadFailure: reportGithubReadFailure }), + github: createArtifactGithubSource(env), bucket: env.PUBLIC_ARTIFACTS, signer: createArtifactSigner(env.ARTIFACT_SIGNER), trustedRoot: parseTrustedRoot(env.ARTIFACT_TRUSTED_ROOT_JSON, Math.floor(Date.now() / 1000)), diff --git a/nuxt.config.ts b/nuxt.config.ts index 2b45c2e2..78fa4bb3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -142,6 +142,9 @@ export default defineNuxtConfig({ 'error.count', 'failed.count', 'failed.tasks', + // `artifact-github-credential`: which fallback token a build read GitHub + // with after the read App gave none. + 'github.credential', 'github.endpoint', 'github.step', 'item.count', diff --git a/test/unit/artifact-github-read-app.test.ts b/test/unit/artifact-github-read-app.test.ts new file mode 100644 index 00000000..95f3a5d4 --- /dev/null +++ b/test/unit/artifact-github-read-app.test.ts @@ -0,0 +1,204 @@ +import type { SourceRequest } from '../../layers/artifact-delivery/server/schemas/contracts' +import type { ArtifactGithubCredentialReport } from '../../layers/artifact-delivery/server/utils/github-read-credential' +import { describe, expect, it } from 'vitest' +import { createInstallationTokenCache } from '../../layers/artifact-delivery/server/utils/github-read-credential' +import { createArtifactGithubSource } from '../../layers/artifact-delivery/server/utils/queue' + +// Builds shared the site token with the hourly registry sync and every `/gh` +// page view, so a run failed RATE_LIMITED whenever the sync spent it. The read +// App's installation has its own bucket of 5,000 requests an hour. +const APP_ID = '5212127' +const INSTALLATION_ID = '168540695' +const NOW = 1_791_300_000 +const request: SourceRequest = { + provider: 'github', + owner: 'skilld-dev', + repository: 'skills', + selector: { type: 'path', path: 'skills/demo' }, +} + +describe('the GitHub credential public Artifact builds read with', () => { + it('reads with the read App installation token when the App secrets are set', async () => { + const keys = await appKeys() + const github = fakeGithub({ expiresAt: NOW + 3600 }) + const source = createArtifactGithubSource(appEnv(keys.pem), runtime(github)) + + await source.resolve(request) + + expect(github.mints).toHaveLength(1) + expect(github.mints[0]!.url).toBe(`https://api.github.com/app/installations/${INSTALLATION_ID}/access_tokens`) + expect(github.mints[0]!.body).toEqual({ permissions: { metadata: 'read' } }) + const claims = await verifiedJwtClaims(github.mints[0]!.authorization, keys.publicKey) + expect(claims.iss).toBe(APP_ID) + expect(claims.exp - claims.iat).toBeLessThanOrEqual(600) + expect(github.readAuthorizations).toEqual(['Bearer ghs_installation_1']) + }) + + it('reuses one installation token until five minutes before it expires', async () => { + const keys = await appKeys() + const github = fakeGithub({ expiresAt: NOW + 3600 }) + const clock = { now: NOW } + const cache = createInstallationTokenCache() + const read = () => createArtifactGithubSource(appEnv(keys.pem), runtime(github, { cache, now: () => clock.now })).resolve(request) + + await read() + clock.now = NOW + 3600 - 301 + await read() + expect(github.mints).toHaveLength(1) + + clock.now = NOW + 3600 - 300 + await read() + expect(github.mints).toHaveLength(2) + expect(github.readAuthorizations).toEqual([ + 'Bearer ghs_installation_1', + 'Bearer ghs_installation_1', + 'Bearer ghs_installation_2', + ]) + }) + + it('mints once for reads that start together', async () => { + const keys = await appKeys() + const github = fakeGithub({ expiresAt: NOW + 3600 }) + const source = createArtifactGithubSource(appEnv(keys.pem), runtime(github)) + + await Promise.all([source.resolve(request), source.resolve(request), source.resolve(request)]) + + expect(github.mints).toHaveLength(1) + }) + + it('accepts a PEM whose line breaks were stored as \\n', async () => { + const keys = await appKeys() + const github = fakeGithub({ expiresAt: NOW + 3600 }) + const source = createArtifactGithubSource(appEnv(keys.pem.replaceAll('\n', '\\n')), runtime(github)) + + await source.resolve(request) + + expect(github.readAuthorizations).toEqual(['Bearer ghs_installation_1']) + }) + + it('falls back to ARTIFACT_GITHUB_TOKEN, then GITHUB_TOKEN, without the App secrets', async () => { + const both = fakeGithub({ expiresAt: NOW + 3600 }) + await createArtifactGithubSource({ ARTIFACT_GITHUB_TOKEN: 'runs', GITHUB_TOKEN: 'site' }, runtime(both)).resolve(request) + const siteOnly = fakeGithub({ expiresAt: NOW + 3600 }) + await createArtifactGithubSource({ ARTIFACT_GITHUB_TOKEN: '', GITHUB_TOKEN: 'site' }, runtime(siteOnly)).resolve(request) + + expect(both.mints).toHaveLength(0) + expect(both.readAuthorizations).toEqual(['Bearer runs']) + expect(siteOnly.readAuthorizations).toEqual(['Bearer site']) + }) + + it('reads with the fallback token, and reports why, when GitHub refuses the installation token', async () => { + const keys = await appKeys() + const github = fakeGithub({ expiresAt: NOW + 3600, mintStatus: 401 }) + const reports: ArtifactGithubCredentialReport[] = [] + const source = createArtifactGithubSource(appEnv(keys.pem), runtime(github, { report: event => reports.push(event) })) + + await source.resolve(request) + + expect(github.readAuthorizations).toEqual(['Bearer runs']) + expect(reports).toEqual([{ outcome: 'app-token-unavailable', reason: 'GitHub refused the installation token (401)', fallback: 'ARTIFACT_GITHUB_TOKEN' }]) + }) + + it('reports an unreadable App key and reads with the fallback token', async () => { + const github = fakeGithub({ expiresAt: NOW + 3600 }) + const reports: ArtifactGithubCredentialReport[] = [] + const env = { ...appEnv('-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----'), ARTIFACT_GITHUB_TOKEN: '' } + const source = createArtifactGithubSource(env, runtime(github, { report: event => reports.push(event) })) + + await source.resolve(request) + + expect(github.mints).toHaveLength(0) + expect(github.readAuthorizations).toEqual(['Bearer site']) + expect(reports).toEqual([{ outcome: 'app-misconfigured', reason: 'SKILLD_READ_APP_PRIVATE_KEY_PKCS8 is not a PKCS #8 PEM private key', fallback: 'GITHUB_TOKEN' }]) + }) + + it('never puts a token or the key in a report', async () => { + const keys = await appKeys() + const github = fakeGithub({ expiresAt: NOW + 3600, mintStatus: 500 }) + const reports: ArtifactGithubCredentialReport[] = [] + await createArtifactGithubSource(appEnv(keys.pem), runtime(github, { report: event => reports.push(event) })).resolve(request) + + const text = JSON.stringify(reports) + expect(text).not.toContain('runs') + expect(text).not.toContain('site') + expect(text).not.toContain('PRIVATE KEY') + expect(reports).toHaveLength(1) + }) +}) + +interface FakeGithub { + fetch: typeof fetch + mints: Array<{ url: string, authorization: string, body: unknown }> + readAuthorizations: Array +} + +function fakeGithub(options: { expiresAt: number, mintStatus?: number }): FakeGithub { + const mints: FakeGithub['mints'] = [] + const readAuthorizations: FakeGithub['readAuthorizations'] = [] + const fetcher = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input) + const headers = new Headers(init?.headers) + if (url.includes('/access_tokens')) { + mints.push({ url, authorization: headers.get('authorization') ?? '', body: JSON.parse(String(init?.body)) }) + if (options.mintStatus) + return new Response('{}', { status: options.mintStatus }) + return Response.json({ token: `ghs_installation_${mints.length}`, expires_at: new Date(options.expiresAt * 1000).toISOString() }, { status: 201 }) + } + readAuthorizations.push(headers.get('authorization')) + return Response.json({ message: 'Not Found' }, { status: 404 }) + } + return { fetch: fetcher as typeof fetch, mints, readAuthorizations } +} + +function runtime( + github: FakeGithub, + overrides: Partial<{ cache: ReturnType, now: () => number, report: (event: ArtifactGithubCredentialReport) => void }> = {}, +) { + return { + fetch: github.fetch, + now: overrides.now ?? (() => NOW), + tokenCache: overrides.cache ?? createInstallationTokenCache(), + report: overrides.report ?? (() => {}), + } +} + +function appEnv(pem: string) { + return { + SKILLD_READ_APP_ID: APP_ID, + SKILLD_READ_APP_INSTALLATION_ID: INSTALLATION_ID, + SKILLD_READ_APP_PRIVATE_KEY_PKCS8: pem, + ARTIFACT_GITHUB_TOKEN: 'runs', + GITHUB_TOKEN: 'site', + } +} + +async function appKeys(): Promise<{ pem: string, publicKey: CryptoKey }> { + const pair = 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 + const der = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey) as ArrayBuffer) + const base64 = btoa(String.fromCharCode(...der)).match(/.{1,64}/g)!.join('\n') + return { pem: `-----BEGIN PRIVATE KEY-----\n${base64}\n-----END PRIVATE KEY-----\n`, publicKey: pair.publicKey } +} + +async function verifiedJwtClaims(authorization: string, publicKey: CryptoKey): Promise<{ iss: string, iat: number, exp: number }> { + const jwt = authorization.replace(/^Bearer /, '') + const [header, payload, signature] = jwt.split('.') as [string, string, string] + const valid = await crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + publicKey, + base64UrlBytes(signature), + new TextEncoder().encode(`${header}.${payload}`), + ) + expect(valid).toBe(true) + expect(JSON.parse(new TextDecoder().decode(base64UrlBytes(header)))).toEqual({ alg: 'RS256', typ: 'JWT' }) + return JSON.parse(new TextDecoder().decode(base64UrlBytes(payload))) +} + +function base64UrlBytes(value: string): Uint8Array { + const normalized = value.replaceAll('-', '+').replaceAll('_', '/') + const binary = atob(normalized + '='.repeat((4 - normalized.length % 4) % 4)) + return Uint8Array.from(binary, character => character.charCodeAt(0)) +} diff --git a/test/unit/artifact-rate-limit.test.ts b/test/unit/artifact-rate-limit.test.ts index 4d7437ed..133cec2e 100644 --- a/test/unit/artifact-rate-limit.test.ts +++ b/test/unit/artifact-rate-limit.test.ts @@ -9,7 +9,7 @@ import { createPublicGithubSourceClient, isRetryableProblem, } from '../../layers/artifact-delivery/server/utils/github-source' -import { ARTIFACT_BUILD_QUEUE_NAME, artifactGithubToken, consumeArtifactBuildBatch } from '../../layers/artifact-delivery/server/utils/queue' +import { ARTIFACT_BUILD_QUEUE_NAME, consumeArtifactBuildBatch } from '../../layers/artifact-delivery/server/utils/queue' import { createResolution, getResolution, presentResolution, resolutionRequestIdentity } from '../../layers/artifact-delivery/server/utils/state' import { createSqliteD1 } from './helpers/d1-sqlite' @@ -216,20 +216,6 @@ describe('gitHub secondary rate limits as values', () => { }) }) -// The hourly registry sync spends the site token to zero before each GitHub -// reset. On 2026-10-06, 13 runs failed RATE_LIMITED in those minutes. A token -// from another GitHub account carries its own quota, kept for runs. -describe('the token Artifact builds read GitHub with', () => { - it('prefers the token kept for Artifact builds', () => { - expect(artifactGithubToken({ ARTIFACT_GITHUB_TOKEN: 'runs', GITHUB_TOKEN: 'site' })).toBe('runs') - }) - - it('uses the site token while no Artifact token is set', () => { - expect(artifactGithubToken({ ARTIFACT_GITHUB_TOKEN: '', GITHUB_TOKEN: 'site' })).toBe('site') - expect(artifactGithubToken({ GITHUB_TOKEN: 'site' })).toBe('site') - }) -}) - describe('which problems a caller may retry', () => { it.each([ ['RATE_LIMITED', true], diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index dd96a883..cafb6af1 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --config=wrangler.jsonc --env-file=.dev.vars.example` (hash: 44fdc49aa25ec9befef6b0cb85d9da7e) +// Generated by Wrangler by running `wrangler types --config=wrangler.jsonc --env-file=.dev.vars.example` (hash: 91ae1b6a0c6d3e279dd2c18ecea4e0c4) // Runtime types generated with workerd@1.20260828.1 2026-08-20 nodejs_compat interface __BaseEnv_Env { KV_CACHE: KVNamespace; @@ -38,6 +38,9 @@ interface __BaseEnv_Env { NUXT_AI_READY_RUNTIME_SYNC_SECRET: string; GITHUB_TOKEN: string; ARTIFACT_GITHUB_TOKEN: string; + SKILLD_READ_APP_ID: string; + SKILLD_READ_APP_INSTALLATION_ID: string; + SKILLD_READ_APP_PRIVATE_KEY_PKCS8: string; ARTIFACT_PRIVATE_ACCESS_ENABLED: string; GITHUB_APP_ID: string; GITHUB_APP_CLIENT_ID: string; @@ -65,7 +68,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types