diff --git a/src/lib/embed-upload.ts b/src/lib/embed-upload.ts index c639c43..9d7e076 100644 --- a/src/lib/embed-upload.ts +++ b/src/lib/embed-upload.ts @@ -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 `. 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 @@ -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 } + | { ok: false; status: 401 | 403 | 503; message: string } export function checkBearer( authHeader: string | null, @@ -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 { + 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. */ diff --git a/src/lib/github-oidc.test.ts b/src/lib/github-oidc.test.ts new file mode 100644 index 0000000..e1fed4d --- /dev/null +++ b/src/lib/github-oidc.test.ts @@ -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 { + 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 { + 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 { + 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) + }) +}) diff --git a/src/lib/github-oidc.ts b/src/lib/github-oidc.ts new file mode 100644 index 0000000..b37fc15 --- /dev/null +++ b/src/lib/github-oidc.ts @@ -0,0 +1,307 @@ +/** + * GitHub Actions OIDC verification for the project-embed upload endpoint. + * + * Replaces the static `PROJECT_EMBED_UPLOAD_TOKEN` shared bearer: + * - any GitHub Actions workflow under our owner with `permissions: id-token: write` + * can mint an OIDC token (a short-lived JWT) and present it as the bearer. + * - the Worker verifies the token's signature against GitHub's published JWKS + * and authorizes on `repository_owner === ALLOWED_OWNER`. + * + * Why this is the right answer: + * - zero-secret per-repo: any new repo we create just inherits authority from + * the org/user-level OIDC trust relationship. No `gh secret set` step. + * - tokens are minute-scope, so leakage windows are tiny. + * - the audit trail is rich: the JWT carries `repository`, `ref`, `sha`, + * `actor`, `workflow`, `job_workflow_ref` — much better than "someone with + * the bearer". + * + * References: + * https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect + * https://token.actions.githubusercontent.com/.well-known/openid-configuration + */ + +const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com' +const GITHUB_OIDC_JWKS_URI = 'https://token.actions.githubusercontent.com/.well-known/jwks' + +/** + * Owner whose workflows are trusted to upload embeds. + * Currently a single value; if we ever need multi-owner trust (e.g. orgs + + * user repos), promote to a Set. + */ +export const ALLOWED_OWNER = 'baladithyab' + +/** + * Default audience the workflow should request when minting its OIDC token. + * The workflow side can pick any string here; we just have to agree on it. + * Using the public site origin keeps it self-documenting. + */ +export const EXPECTED_AUDIENCE = 'https://codeseys.io' + +/** A clock-skew allowance for `nbf`/`iat`/`exp` checks (5 seconds). */ +const CLOCK_SKEW_SEC = 5 + +/** Lifetime of in-memory JWKS cache (1 hour). GitHub rotates rarely. */ +const JWKS_CACHE_TTL_MS = 60 * 60 * 1000 + +// ----------------------------- types ------------------------------------ + +export type GithubOidcClaims = { + iss: string + aud: string | string[] + sub: string + iat: number + nbf?: number + exp: number + // GitHub-specific claims (subset). + repository?: string + repository_owner?: string + repository_id?: string + repository_owner_id?: string + workflow?: string + workflow_ref?: string + job_workflow_ref?: string + ref?: string + ref_type?: string + sha?: string + actor?: string + actor_id?: string + event_name?: string + run_id?: string + run_number?: string + run_attempt?: string + environment?: string +} + +export type OidcVerifyResult = + | { ok: true; claims: GithubOidcClaims } + | { ok: false; status: 401 | 403 | 503; message: string; details?: unknown } + +type Jwk = { + kid: string + kty: string + use?: string + alg?: string + n?: string + e?: string +} + +type JwksDoc = { keys: Jwk[] } + +// ----------------------------- JWKS cache ---------------------------- + +let jwksCache: { fetchedAt: number; doc: JwksDoc } | null = null + +/** + * Fetch GitHub's JWKS document, with a 1-hour in-memory cache. + * Falls back to refreshing if the cache misses on a kid lookup later. + */ +export async function getGithubJwks(forceRefresh = false): Promise { + if ( + !forceRefresh && + jwksCache !== null && + Date.now() - jwksCache.fetchedAt < JWKS_CACHE_TTL_MS + ) { + return jwksCache.doc + } + const resp = await fetch(GITHUB_OIDC_JWKS_URI, { + headers: { Accept: 'application/json' }, + }) + if (!resp.ok) { + throw new Error(`JWKS fetch failed: HTTP ${resp.status}`) + } + const doc = (await resp.json()) as JwksDoc + jwksCache = { fetchedAt: Date.now(), doc } + return doc +} + +// ----------------------------- low-level helpers --------------------- + +/** base64url -> Uint8Array. */ +function base64urlDecode(input: string): Uint8Array { + // Pad out to base64. + const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4) + const padded = input + '='.repeat(pad) + const b64 = padded.replace(/-/g, '+').replace(/_/g, '/') + const bin = atob(b64) + const out = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out +} + +/** UTF-8 decode bytes. */ +function utf8Decode(buf: Uint8Array): string { + return new TextDecoder().decode(buf) +} + +/** Find the JWK whose kid matches the JWS header. */ +function findKey(doc: JwksDoc, kid: string): Jwk | null { + return doc.keys.find((k) => k.kid === kid) ?? null +} + +/** Convert an RSA public JWK to a CryptoKey for RS256 verification. */ +async function importRsaKey(jwk: Jwk): Promise { + if (jwk.kty !== 'RSA' || !jwk.n || !jwk.e) { + throw new Error('JWKS key is not an RSA public key') + } + return crypto.subtle.importKey( + 'jwk', + { + kty: 'RSA', + n: jwk.n, + e: jwk.e, + alg: 'RS256', + ext: true, + }, + { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + false, + ['verify'] + ) +} + +// ----------------------------- main verify -------------------------- + +/** + * Verify a GitHub Actions OIDC ID token. + * + * @param idToken Raw JWT (the value after `Authorization: Bearer `). + * @param expectedAud Audience the calling workflow used (defaults to the + * site origin). Pass `null` to accept any audience — + * NOT recommended in production. + * @param now Optional clock injection for tests. + */ +export async function verifyGithubOidc( + idToken: string, + expectedAud: string | null = EXPECTED_AUDIENCE, + now: number = Date.now() +): Promise { + const parts = idToken.split('.') + if (parts.length !== 3) { + return { ok: false, status: 401, message: 'Token is not a JWS-Compact JWT' } + } + const [encodedHeader, encodedPayload, encodedSig] = parts + + // Decode header + payload. + let header: { alg?: string; kid?: string; typ?: string } + let claims: GithubOidcClaims + try { + header = JSON.parse(utf8Decode(base64urlDecode(encodedHeader))) + claims = JSON.parse(utf8Decode(base64urlDecode(encodedPayload))) + } catch (err) { + return { ok: false, status: 401, message: 'Token header/payload is not valid JSON' } + } + + if (header.alg !== 'RS256') { + return { ok: false, status: 401, message: `Unexpected alg: ${header.alg}` } + } + if (!header.kid) { + return { ok: false, status: 401, message: 'Missing kid in JWT header' } + } + + // Issuer + audience checks. + if (claims.iss !== GITHUB_OIDC_ISSUER) { + return { ok: false, status: 401, message: 'Issuer mismatch' } + } + if (expectedAud !== null) { + const audOk = Array.isArray(claims.aud) + ? claims.aud.includes(expectedAud) + : claims.aud === expectedAud + if (!audOk) { + return { ok: false, status: 401, message: 'Audience mismatch' } + } + } + + // Time window. + const nowSec = Math.floor(now / 1000) + if (typeof claims.exp !== 'number' || nowSec > claims.exp + CLOCK_SKEW_SEC) { + return { ok: false, status: 401, message: 'Token expired' } + } + if (typeof claims.nbf === 'number' && nowSec + CLOCK_SKEW_SEC < claims.nbf) { + return { ok: false, status: 401, message: 'Token not yet valid' } + } + + // Find the signing key. + let jwks = await getGithubJwks(false) + let jwk = findKey(jwks, header.kid) + if (!jwk) { + // Cache miss — refresh once before giving up. + jwks = await getGithubJwks(true) + jwk = findKey(jwks, header.kid) + } + if (!jwk) { + return { + ok: false, + status: 503, + message: `Signing key '${header.kid}' not in GitHub JWKS`, + } + } + + // Verify signature. + let key: CryptoKey + try { + key = await importRsaKey(jwk) + } catch (err) { + return { + ok: false, + status: 503, + message: 'Failed to import JWKS public key', + details: err instanceof Error ? err.message : String(err), + } + } + + const signedInput = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`) + const signature = base64urlDecode(encodedSig) + // Wrap into a fresh ArrayBuffer to satisfy strict BufferSource typing + // (Uint8Array may be backed by SharedArrayBuffer per + // recent lib.dom.d.ts; crypto.subtle.verify wants ArrayBuffer-backed). + const sigBuf = signature.slice().buffer + const inputBuf = signedInput.slice().buffer + let valid = false + try { + valid = await crypto.subtle.verify( + { name: 'RSASSA-PKCS1-v1_5' }, + key, + sigBuf, + inputBuf + ) + } catch (err) { + return { + ok: false, + status: 401, + message: 'Signature verification threw', + details: err instanceof Error ? err.message : String(err), + } + } + if (!valid) { + return { ok: false, status: 401, message: 'Signature verification failed' } + } + + return { ok: true, claims } +} + +// ----------------------------- authorization ------------------------ + +export type OidcAuthzResult = + | { ok: true; claims: GithubOidcClaims } + | { ok: false; status: 403; message: string } + +/** + * Given verified claims, decide whether the workflow is authorized to + * upload embeds. Today: must be from `repository_owner === ALLOWED_OWNER`. + * + * Future hardening hooks (commented out for now): + * - require `event_name === 'push'` or `'workflow_dispatch'` (block PRs from forks) + * - require `ref === 'refs/heads/master'` or `'refs/heads/main'` + * - require `job_workflow_ref` to start with `baladithyab/web-embed-workflows/` + * (so only OUR reusable workflow gets to upload, not arbitrary workflows + * in our repos) + */ +export function authorizeForEmbedUpload(claims: GithubOidcClaims): OidcAuthzResult { + if (claims.repository_owner !== ALLOWED_OWNER) { + return { + ok: false, + status: 403, + message: `repository_owner '${claims.repository_owner}' is not authorized`, + } + } + return { ok: true, claims } +} diff --git a/src/pages/api/embed-upload.ts b/src/pages/api/embed-upload.ts index d5ae3f7..bdb9e82 100644 --- a/src/pages/api/embed-upload.ts +++ b/src/pages/api/embed-upload.ts @@ -18,8 +18,8 @@ import type { APIRoute } from 'astro' import { getRuntimeEnv } from '@/lib/runtime-env' import { type EmbedUploadEnv, + authenticateUpload, buildKey, - checkBearer, contentTypeForPath, isSafeRelativePath, SLUG_RE, @@ -57,8 +57,8 @@ export const GET: APIRoute = () => export const PUT: APIRoute = async ({ request }) => { const env = getRuntimeEnv() - // Auth. - const auth = checkBearer(request.headers.get('Authorization'), env.PROJECT_EMBED_UPLOAD_TOKEN) + // Auth (OIDC primary, static bearer fallback). + const auth = await authenticateUpload(request.headers.get('Authorization'), env) if (!auth.ok) { return jsonResponse({ error: auth.message }, auth.status) }