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
79 changes: 71 additions & 8 deletions src/lib/embed-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@
* `/api/embed-upload` endpoint. Lives in `src/lib/` so it can be unit-tested
* without spinning up an Astro request.
*
* Auth model: a single shared bearer token (`PROJECT_EMBED_UPLOAD_TOKEN`)
* is kept as a Cloudflare Worker secret. CI workflows that build project
* artifacts authenticate with that token. The bucket itself never needs
* S3-compatible API keys — the Worker holds the R2 binding and is the
* sole writer.
* Auth model (current):
* PRIMARY: GitHub Actions OIDC. Workflows mint a short-lived ID token and
* present it as `Authorization: Bearer <id_token>`. The Worker verifies
* signature against GitHub's JWKS and authorizes on `repository_owner`.
* Zero per-repo config needed for any future embed repo.
*
* FALLBACK: a static shared bearer (`PROJECT_EMBED_UPLOAD_TOKEN` Worker
* secret). Kept around for manual uploads / local dev / one-shot scripts.
* When OIDC is available, prefer it; static bearer is a safety net.
*
* The bucket itself never needs S3-compatible API keys — the Worker holds
* the R2 binding and is the sole writer.
*/
import { authorizeForEmbedUpload, verifyGithubOidc } from './github-oidc'

export type EmbedUploadEnv = {
PROJECT_ASSETS?: R2Bucket
Expand Down Expand Up @@ -50,8 +58,8 @@ export function timingSafeEqual(a: string, b: string): boolean {
}

export type AuthResult =
| { ok: true }
| { ok: false; status: 401 | 503; message: string }
| { ok: true; via: 'oidc' | 'bearer'; details?: Record<string, unknown> }
| { ok: false; status: 401 | 403 | 503; message: string }

export function checkBearer(
authHeader: string | null,
Expand All @@ -71,7 +79,62 @@ export function checkBearer(
if (!timingSafeEqual(presented, expectedToken)) {
return { ok: false, status: 401, message: 'Invalid Bearer token' }
}
return { ok: true }
return { ok: true, via: 'bearer' }
}

/**
* Heuristic: a GitHub Actions OIDC ID token is a JWS-Compact-form RS256
* JWT — three base64url segments separated by dots, ~1.5KB long. A static
* bearer is whatever string the user generated (we use 47-char base64url
* tokens that don't contain dots).
*
* If we see ≥2 dots, try OIDC first; otherwise treat it as a static bearer.
*/
function looksLikeJwt(token: string): boolean {
return token.split('.').length === 3
}

/**
* Authenticate an upload request. Prefers GitHub Actions OIDC; falls back
* to the static `PROJECT_EMBED_UPLOAD_TOKEN` shared bearer.
*/
export async function authenticateUpload(
authHeader: string | null,
env: EmbedUploadEnv
): Promise<AuthResult> {
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return { ok: false, status: 401, message: 'Missing Bearer token' }
}
const presented = authHeader.slice('Bearer '.length).trim()

// OIDC path: looks like a JWT, try GitHub OIDC verification + authorization.
if (looksLikeJwt(presented)) {
const verified = await verifyGithubOidc(presented)
if (!verified.ok) {
// Don't fall back to bearer for token-shaped strings — if it parses
// as a JWT but verification failed, that's a real auth failure.
return verified
}
const authz = authorizeForEmbedUpload(verified.claims)
if (!authz.ok) {
return authz
}
return {
ok: true,
via: 'oidc',
details: {
repository: verified.claims.repository,
ref: verified.claims.ref,
sha: verified.claims.sha,
actor: verified.claims.actor,
workflow: verified.claims.workflow,
run_id: verified.claims.run_id,
},
}
}

// Static bearer fallback.
return checkBearer(authHeader, env.PROJECT_EMBED_UPLOAD_TOKEN)
}

/** Build the R2 object key from manifest-derived components. */
Expand Down
269 changes: 269 additions & 0 deletions src/lib/github-oidc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
/**
* Unit tests for the GitHub Actions OIDC verifier.
*
* Approach: generate an RSA keypair locally, sign a synthesized JWT with
* GitHub-shaped claims, monkey-patch `getGithubJwks` (via a stubbed module)
* to return our public key under the kid we used for signing, and run
* verify. Covers happy path + every rejection branch.
*/
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
import {
ALLOWED_OWNER,
EXPECTED_AUDIENCE,
authorizeForEmbedUpload,
verifyGithubOidc,
type GithubOidcClaims,
} from './github-oidc'

// ----------------------------- helpers ----------------------------------

const TEST_KID = 'test-kid-1'

let testKeyPair: CryptoKeyPair
let testJwk: { kid: string; kty: string; n: string; e: string; alg: string }

async function makeTestKeys(): Promise<void> {
testKeyPair = await crypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify']
)
const jwk = await crypto.subtle.exportKey('jwk', testKeyPair.publicKey)
testJwk = {
kid: TEST_KID,
kty: jwk.kty as string,
n: jwk.n as string,
e: jwk.e as string,
alg: 'RS256',
}
}

function base64urlEncode(bytes: ArrayBuffer | Uint8Array): string {
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
let bin = ''
for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i])
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

function base64urlEncodeJson(obj: unknown): string {
return base64urlEncode(new TextEncoder().encode(JSON.stringify(obj)))
}

async function signTestJwt(
payload: object,
opts: { alg?: string; kid?: string; corruptSig?: boolean } = {}
): Promise<string> {
const header = { alg: opts.alg ?? 'RS256', typ: 'JWT', kid: opts.kid ?? TEST_KID }
const encodedHeader = base64urlEncodeJson(header)
const encodedPayload = base64urlEncodeJson(payload)
const signingInput = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)

const sig = await crypto.subtle.sign(
{ name: 'RSASSA-PKCS1-v1_5' },
testKeyPair.privateKey,
signingInput
)
const sigBytes = opts.corruptSig
? (() => {
const u = new Uint8Array(sig)
u[0] ^= 0xff
return u.buffer
})()
: sig
const encodedSig = base64urlEncode(sigBytes)
return `${encodedHeader}.${encodedPayload}.${encodedSig}`
}

function makeClaims(overrides: Partial<GithubOidcClaims> = {}): GithubOidcClaims {
const nowSec = Math.floor(Date.now() / 1000)
return {
iss: 'https://token.actions.githubusercontent.com',
aud: EXPECTED_AUDIENCE,
sub: 'repo:baladithyab/UCSC-CSE-101-W20:ref:refs/heads/master',
iat: nowSec,
nbf: nowSec,
exp: nowSec + 600,
repository: 'baladithyab/UCSC-CSE-101-W20',
repository_owner: ALLOWED_OWNER,
repository_id: '236258028',
repository_owner_id: '15759113',
workflow: 'build web embed',
workflow_ref: 'baladithyab/UCSC-CSE-101-W20/.github/workflows/build-web-asset.yml@refs/heads/master',
job_workflow_ref: 'baladithyab/web-embed-workflows/.github/workflows/static-passthrough.yml@refs/heads/main',
ref: 'refs/heads/master',
ref_type: 'branch',
sha: '7321682d39ab2bda7af0bd1bb1bc0db8a02a73eb',
actor: 'baladithyab',
actor_id: '15759113',
event_name: 'push',
run_id: '999999999',
run_number: '1',
run_attempt: '1',
...overrides,
}
}

// Fake the global `fetch` for the JWKS endpoint.
let originalFetch: typeof globalThis.fetch
beforeEach(async () => {
if (!testKeyPair) await makeTestKeys()
originalFetch = globalThis.fetch
globalThis.fetch = vi.fn(async (input: Request | string | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url.startsWith('https://token.actions.githubusercontent.com/.well-known/jwks')) {
return new Response(JSON.stringify({ keys: [testJwk] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
return new Response('not mocked', { status: 404 })
}) as unknown as typeof fetch
})
afterEach(() => {
globalThis.fetch = originalFetch
// Reset module-level JWKS cache between tests by re-importing — but since
// verifyGithubOidc swallows and re-fetches on cache miss, force-refresh
// is exercised inside specific tests.
})

// ----------------------------- tests ------------------------------------

describe('verifyGithubOidc', () => {
it('accepts a well-formed token from our owner', async () => {
const jwt = await signTestJwt(makeClaims())
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.claims.repository_owner).toBe(ALLOWED_OWNER)
expect(r.claims.repository).toBe('baladithyab/UCSC-CSE-101-W20')
}
})

it('rejects a malformed (non-JWS-Compact) token', async () => {
const r = await verifyGithubOidc('not.a.token.at.all')
expect(r.ok).toBe(false)
if (!r.ok) expect(r.status).toBe(401)
})

it('rejects a token with two segments', async () => {
const r = await verifyGithubOidc('only.two')
expect(r.ok).toBe(false)
if (!r.ok) expect(r.message).toMatch(/JWS-Compact/)
})

it('rejects a token whose alg is not RS256', async () => {
const jwt = await signTestJwt(makeClaims(), { alg: 'HS256' })
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.message).toMatch(/alg/i)
})

it('rejects a token without kid in header', async () => {
// Manually craft a header without kid.
const header = { alg: 'RS256', typ: 'JWT' }
const payload = makeClaims()
const eh = base64urlEncodeJson(header)
const ep = base64urlEncodeJson(payload)
const sig = await crypto.subtle.sign(
{ name: 'RSASSA-PKCS1-v1_5' },
testKeyPair.privateKey,
new TextEncoder().encode(`${eh}.${ep}`)
)
const r = await verifyGithubOidc(`${eh}.${ep}.${base64urlEncode(sig)}`)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.message).toMatch(/kid/i)
})

it('rejects a token from a wrong issuer', async () => {
const jwt = await signTestJwt(makeClaims({ iss: 'https://evil.example.com' }))
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.message).toMatch(/Issuer/i)
})

it('rejects a token whose audience does not match', async () => {
const jwt = await signTestJwt(makeClaims({ aud: 'https://other-site.example' }))
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.message).toMatch(/Audience/i)
})

it('accepts when audience is in an array', async () => {
const jwt = await signTestJwt(
makeClaims({ aud: ['https://other.example', EXPECTED_AUDIENCE] })
)
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(true)
})

it('accepts any audience when expectedAud is null', async () => {
const jwt = await signTestJwt(makeClaims({ aud: 'https://random.example' }))
const r = await verifyGithubOidc(jwt, null)
expect(r.ok).toBe(true)
})

it('rejects an expired token', async () => {
const oldNow = Math.floor(Date.now() / 1000) - 7200
const jwt = await signTestJwt(
makeClaims({ iat: oldNow, nbf: oldNow, exp: oldNow + 600 })
)
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.message).toMatch(/expired/i)
})

it('rejects a token before its nbf', async () => {
const futureNow = Math.floor(Date.now() / 1000) + 7200
const jwt = await signTestJwt(
makeClaims({ iat: futureNow, nbf: futureNow, exp: futureNow + 600 })
)
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.message).toMatch(/not yet valid/i)
})

it('rejects a token with a corrupted signature', async () => {
const jwt = await signTestJwt(makeClaims(), { corruptSig: true })
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.message).toMatch(/Signature/i)
})

it('rejects a token whose kid is not in JWKS even after refresh', async () => {
const jwt = await signTestJwt(makeClaims(), { kid: 'unknown-kid' })
const r = await verifyGithubOidc(jwt)
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.status).toBe(503)
expect(r.message).toMatch(/not in GitHub JWKS/i)
}
})
})

describe('authorizeForEmbedUpload', () => {
it('accepts our owner', () => {
const r = authorizeForEmbedUpload(makeClaims())
expect(r.ok).toBe(true)
})

it('rejects another owner', () => {
const r = authorizeForEmbedUpload(makeClaims({ repository_owner: 'someone-else' }))
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.status).toBe(403)
expect(r.message).toMatch(/repository_owner/)
}
})

it('rejects a missing owner', () => {
const claims = makeClaims()
delete claims.repository_owner
const r = authorizeForEmbedUpload(claims)
expect(r.ok).toBe(false)
})
})
Loading
Loading