diff --git a/packages/worker-utils/package.json b/packages/worker-utils/package.json index 5716fb87b5..29ef44db00 100644 --- a/packages/worker-utils/package.json +++ b/packages/worker-utils/package.json @@ -21,6 +21,7 @@ "./kilo-pass-bonus-projection": "./src/kilo-pass-bonus-projection.ts", "./git-url": "./src/git-url.ts", "./callback-token": "./src/callback-token.ts", + "./cf-access": "./src/cf-access.ts", "./cloud-agent-next-client": "./src/cloud-agent-next-client.ts", "./cloud-agent-session-access": "./src/cloud-agent-session-access.ts", "./kilo-model-id": "./src/kilo-model-id.ts", diff --git a/services/gastown/src/middleware/cf-access.middleware.ts b/packages/worker-utils/src/cf-access.ts similarity index 90% rename from services/gastown/src/middleware/cf-access.middleware.ts rename to packages/worker-utils/src/cf-access.ts index dd243dc8d0..c0386e20e4 100644 --- a/services/gastown/src/middleware/cf-access.middleware.ts +++ b/packages/worker-utils/src/cf-access.ts @@ -1,7 +1,4 @@ -import { createMiddleware } from 'hono/factory'; -import { seconds } from 'itty-time'; import { z } from 'zod'; -import type { GastownEnv } from '../gastown.worker'; /** * Validate a raw Request against Cloudflare Access. @@ -23,27 +20,6 @@ export async function validateCfAccessRequest( await validateAccessJWT({ request, accessTeamDomain, accessAud }); } -export function withCloudflareAccess({ - team, - audience, -}: { - team: AccessTeam; - audience: AccessAudience; -}) { - return createMiddleware(async (c, next) => { - try { - await validateCfAccessRequest(c.req.raw, { team, audience }); - } catch (e) { - console.warn(`validateAccessJWT failed ${e instanceof Error ? e.message : 'unknown'}`, { - error: e, - }); - return c.json({ success: false, error: 'Unauthorized' }, 401); - } - - await next(); - }); -} - // Access validation code adapted from: // https://github.com/cloudflare/pages-plugins/blob/main/packages/cloudflare-access/functions/_middleware.ts?at=90281ad52b77506bb7723a8db813e19723725509#L88 @@ -81,6 +57,18 @@ function asciiToUint8Array(s: string): ArrayBuffer { return new Uint8Array(chars).buffer; } +// The certs endpoint supports Cloudflare's edge-cache `cf` fetch options, which +// are a Workers-only RequestInit extension absent from the standard webworker +// lib, so the fetch init is typed locally rather than adding workers-types here. +type CloudflareFetchInit = RequestInit & { + cf?: { + cacheEverything?: boolean; + cacheTtl?: number; + }; +}; + +const CF_ACCESS_CERTS_CACHE_TTL_SECONDS = 86_400; // 1 day + async function validateAccessJWT({ request, accessTeamDomain, @@ -101,12 +89,13 @@ async function validateAccessJWT({ const textDecoder = new TextDecoder('utf-8'); const { kid } = AccessHeader.parse(JSON.parse(textDecoder.decode(base64URLDecode(header)))); const certsURL = new URL('/cdn-cgi/access/certs', accessTeamDomain); - const certsResponse = await fetch(certsURL.toString(), { + const certsFetchInit: CloudflareFetchInit = { cf: { cacheEverything: true, - cacheTtl: seconds('1 day'), + cacheTtl: CF_ACCESS_CERTS_CACHE_TTL_SECONDS, }, - }); + }; + const certsResponse = await fetch(certsURL.toString(), certsFetchInit); const { keys } = AccessCertsResponse.parse(await certsResponse.json()); const jwk = keys.find(key => key.kid === kid); if (!jwk) { diff --git a/services/gastown/src/gastown.worker.ts b/services/gastown/src/gastown.worker.ts index 9760500ba1..ce1f3fa066 100644 --- a/services/gastown/src/gastown.worker.ts +++ b/services/gastown/src/gastown.worker.ts @@ -18,7 +18,7 @@ import { type AuthVariables, } from './middleware/auth.middleware'; import { kiloAuthMiddleware } from './middleware/kilo-auth.middleware'; -import { validateCfAccessRequest } from './middleware/cf-access.middleware'; +import { validateCfAccessRequest } from '@kilocode/worker-utils/cf-access'; import { trpcServer } from '@hono/trpc-server'; import { wrappedGastownRouter } from './trpc/router'; diff --git a/services/wasteland/src/middleware/cf-access.middleware.ts b/services/wasteland/src/middleware/cf-access.middleware.ts deleted file mode 100644 index fa60c154ef..0000000000 --- a/services/wasteland/src/middleware/cf-access.middleware.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { createMiddleware } from 'hono/factory'; -import { seconds } from 'itty-time'; -import { z } from 'zod'; -import type { WastelandEnv } from '../wasteland.worker'; - -/** - * Validate a raw Request against Cloudflare Access. - * Throws if the JWT is missing, malformed, or invalid. - */ -export async function validateCfAccessRequest( - request: Request, - { team, audience }: { team: AccessTeam; audience: AccessAudience } -): Promise { - const accessTeamDomain = AccessTeamDomain.parse( - `https://${AccessTeam.parse(team)}.cloudflareaccess.com` - ); - const accessAud = AccessAudience.parse(audience); - - if (!hasValidJWT(request)) { - throw new Error('Missing CF Access JWT'); - } - await validateAccessJWT({ request, accessTeamDomain, accessAud }); -} - -export function withCloudflareAccess({ - team, - audience, -}: { - team: AccessTeam; - audience: AccessAudience; -}) { - return createMiddleware(async (c, next) => { - try { - await validateCfAccessRequest(c.req.raw, { team, audience }); - } catch (e) { - console.warn(`validateAccessJWT failed ${e instanceof Error ? e.message : 'unknown'}`, { - error: e, - }); - return c.json({ success: false, error: 'Unauthorized' }, 401); - } - - await next(); - }); -} - -function extractJWTFromRequest(req: Request): AccessJWT { - return AccessJWT.parse(req.headers.get('Cf-Access-Jwt-Assertion')); -} - -function includesAud(payload: AccessPayload, aud: string): boolean { - if (typeof payload.aud === 'string') { - return payload.aud === aud; - } - return payload.aud.includes(aud); -} - -function hasValidJWT(req: Request): boolean { - try { - extractJWTFromRequest(req); - return true; - } catch { - return false; - } -} - -function base64URLDecode(s: string): ArrayBuffer { - s = s.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, ''); - return new Uint8Array(Array.from(atob(s)).map((c: string) => c.charCodeAt(0))).buffer; -} - -function asciiToUint8Array(s: string): ArrayBuffer { - const chars = []; - for (let i = 0; i < s.length; ++i) { - chars.push(s.charCodeAt(i)); - } - return new Uint8Array(chars).buffer; -} - -async function validateAccessJWT({ - request, - accessTeamDomain, - accessAud, -}: { - request: Request; - accessTeamDomain: AccessTeamDomain; - accessAud: AccessAudience; -}): Promise<{ jwt: string; payload: object }> { - const jwt = extractJWTFromRequest(request); - - const parts = jwt.split('.'); - if (parts.length !== 3) { - throw new Error('JWT does not have three parts.'); - } - const [header, payload, signature] = parts; - - const textDecoder = new TextDecoder('utf-8'); - const { kid } = AccessHeader.parse(JSON.parse(textDecoder.decode(base64URLDecode(header)))); - const certsURL = new URL('/cdn-cgi/access/certs', accessTeamDomain); - const certsResponse = await fetch(certsURL.toString(), { - cf: { - cacheEverything: true, - cacheTtl: seconds('1 day'), - }, - }); - const { keys } = AccessCertsResponse.parse(await certsResponse.json()); - const jwk = keys.find(key => key.kid === kid); - if (!jwk) { - throw new Error('Could not find matching signing key.'); - } - - const key = await crypto.subtle.importKey( - 'jwk', - jwk, - { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, - false, - ['verify'] - ); - - const unroundedSecondsSinceEpoch = Date.now() / 1000; - - const payloadObj = AccessPayload.parse(JSON.parse(textDecoder.decode(base64URLDecode(payload)))); - - if (payloadObj.iss !== certsURL.origin) { - throw new Error('JWT issuer is incorrect.'); - } - if (!includesAud(payloadObj, accessAud)) { - throw new Error('JWT audience is incorrect.'); - } - if (Math.floor(unroundedSecondsSinceEpoch) >= payloadObj.exp) { - throw new Error('JWT has expired.'); - } - if (payloadObj.identity_nonce && Math.ceil(unroundedSecondsSinceEpoch) < payloadObj.nbf) { - throw new Error('JWT is not yet valid.'); - } - - const verified = await crypto.subtle.verify( - 'RSASSA-PKCS1-v1_5', - key, - base64URLDecode(signature), - asciiToUint8Array(`${header}.${payload}`) - ); - if (!verified) { - throw new Error('Could not verify JWT.'); - } - - return { jwt, payload: payloadObj }; -} - -const accessJWTRegex = /^[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+$/i; - -export type AccessJWT = z.infer; -export const AccessJWT = z.string().regex(accessJWTRegex); - -export type AccessTeam = z.infer; -export const AccessTeam = z.string().regex(/^[a-z0-9-]+$/); - -export type AccessTeamDomain = z.infer; -export const AccessTeamDomain = z.string().regex(/^https:\/\/[a-z0-9-]+\.cloudflareaccess\.com$/); - -export type AccessKid = z.infer; -export const AccessKid = z.string().regex(/^[a-f0-9]{64}$/); - -export type AccessAudience = z.infer; -export const AccessAudience = z.string().regex(/^[a-f0-9]{64}$/); - -export type AccessAlgorithm = z.infer; -export const AccessAlgorithm = z.literal('RS256'); - -export type AccessHeader = z.infer; -export const AccessHeader = z.object({ - kid: AccessKid, - alg: AccessAlgorithm, - typ: z.literal('JWT').optional(), -}); - -export type AccessKey = z.infer; -export const AccessKey = z.object({ - kid: AccessKid, - kty: z.literal('RSA'), - alg: AccessAlgorithm, - use: z.string().min(1), - e: z.string().min(1), - n: z.string().min(1), -}); - -export type PublicCERT = z.infer; -const PublicCERT = z.object({ - kid: AccessKid, - cert: z - .string() - .min(1) - .refine( - c => c.includes('-----BEGIN CERTIFICATE-----') && c.includes('-----END CERTIFICATE-----'), - { message: 'invalid cert format - missing or invalid header/footer' } - ), -}); - -export type AccessCertsResponse = z.infer; -export const AccessCertsResponse = z.object({ - keys: z.array(AccessKey).min(1, { message: 'Could not fetch signing keys.' }), - public_cert: PublicCERT, - public_certs: z.array(PublicCERT).min(1, { message: 'Could not fetch public certs.' }), -}); - -export const AccessPayloadCommon = z.object({ - type: z.enum(['app', 'org']), - exp: z.number().min(1), - iat: z.number().min(1), - iss: AccessTeamDomain, -}); - -const ServiceAuthAccessPayload = AccessPayloadCommon.extend({ - aud: AccessAudience, - common_name: z.string().regex(/^[a-f0-9]{32}\.access$/), - sub: z.literal(''), - identity_nonce: z.undefined(), -}); - -const UserAccessPayload = AccessPayloadCommon.extend({ - aud: z.array(AccessAudience), - nbf: z.number().min(1), - email: z - .string() - .min(1) - .refine(e => e.includes('@')), - identity_nonce: z.string().min(1), - sub: z.string().uuid(), - country: z.string().length(2), -}); - -export type AccessPayload = z.infer; -export const AccessPayload = z.union([UserAccessPayload, ServiceAuthAccessPayload]); diff --git a/services/wasteland/src/wasteland.worker.ts b/services/wasteland/src/wasteland.worker.ts index 34fc3372b5..f5a8088cba 100644 --- a/services/wasteland/src/wasteland.worker.ts +++ b/services/wasteland/src/wasteland.worker.ts @@ -12,7 +12,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'; import type { MiddlewareHandler } from 'hono'; import type { AuthVariables } from './middleware/auth.middleware'; import { kiloAuthMiddleware } from './middleware/kilo-auth.middleware'; -import { validateCfAccessRequest } from './middleware/cf-access.middleware'; +import { validateCfAccessRequest } from '@kilocode/worker-utils/cf-access'; import { timingMiddleware } from './middleware/analytics.middleware'; import { wrappedWastelandRouter } from './trpc/router'; import { getWastelandRegistryStub } from './dos/WastelandRegistry.do';