From 964efa2cadbeaad6b344f59c49b985ee9f3a8ea1 Mon Sep 17 00:00:00 2001 From: seaHi Date: Fri, 7 Aug 2026 12:53:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=20Cloudflare=20Acces?= =?UTF-8?q?s=20JWT=20=E5=AE=89=E5=85=A8=E9=AA=8C=E8=AF=81=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks.server.ts | 82 +++++++++++++++++++++-------------- src/lib/server/access.test.ts | 70 ++++++++++++++++++++++++++++++ src/lib/server/access.ts | 63 +++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 32 deletions(-) create mode 100644 src/lib/server/access.test.ts create mode 100644 src/lib/server/access.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index e26f36b..1ab0960 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,41 +1,59 @@ -import type { Handle } from '@sveltejs/kit'; import { dev } from '$app/environment'; +import { verifyAccessJwt } from '$lib/server/access'; +import type { Handle } from '@sveltejs/kit'; + +function unauthorized(): Response { + return new Response('Unauthorized - Access credential verification failed', { + status: 401, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/plain; charset=utf-8' + } + }); +} export const handle: Handle = async ({ event, resolve }) => { - // 拦截 /admin 路由 - if (event.url.pathname.startsWith('/admin')) { - // 优先尝试读取常规 Header (旧版或回源时存在) - let userEmail = event.request.headers.get('cf-access-authenticated-user-email'); + if (!event.url.pathname.startsWith('/admin')) { + return resolve(event); + } - // 针对 Cloudflare Pages,从 JWT Assertion 中解析邮箱 - const jwt = event.request.headers.get('cf-access-jwt-assertion'); - if (!userEmail && jwt) { - try { - const payloadBase64Url = jwt.split('.')[1]; - // Base64URL 转 Base64 - const base64 = payloadBase64Url.replace(/-/g, '+').replace(/_/g, '/'); - // atob 在 Cloudflare Workers/Pages 中原生支持 - const payloadString = atob(base64); - const payload = JSON.parse(payloadString); - if (payload && payload.email) { - userEmail = payload.email; - } - } catch (e) { - console.error('Failed to parse CF Access JWT', e); - } - } - - // 本地开发环境通过 $app/environment 的 dev 变量来判断 - const isLocalDev = dev; - - if (!userEmail && !isLocalDev) { - return new Response('Unauthorized', { status: 401 }); - } - - // 将用户信息存入 locals,供后续使用 + if (dev) { event.locals.user = { - email: userEmail || 'dev@local.host' + email: event.request.headers.get('cf-access-authenticated-user-email') || 'dev@local.host' }; + return resolve(event); + } + + const token = event.request.headers.get('cf-access-jwt-assertion'); + const teamDomain = event.platform?.env.CF_ACCESS_TEAM_DOMAIN; + const audience = event.platform?.env.CF_ACCESS_AUD; + + if (!token || !teamDomain || !audience) { + console.error( + JSON.stringify({ + message: 'Cloudflare Access configuration or JWT is missing', + path: event.url.pathname, + hasToken: Boolean(token), + hasTeamDomain: Boolean(teamDomain), + hasAudience: Boolean(audience) + }) + ); + return unauthorized(); + } + + try { + event.locals.user = { + email: await verifyAccessJwt(token, teamDomain, audience) + }; + } catch (cause) { + console.error( + JSON.stringify({ + message: 'Cloudflare Access JWT verification failed', + path: event.url.pathname, + error: cause instanceof Error ? cause.message : String(cause) + }) + ); + return unauthorized(); } return resolve(event); diff --git a/src/lib/server/access.test.ts b/src/lib/server/access.test.ts new file mode 100644 index 0000000..b23c487 --- /dev/null +++ b/src/lib/server/access.test.ts @@ -0,0 +1,70 @@ +import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { normalizeAccessTeamDomain, verifyAccessJwt } from './access'; + +const issuer = 'https://example.cloudflareaccess.com'; +const audience = 'test-access-audience'; +let privateKey: CryptoKey; +let keySet: ReturnType; + +beforeAll(async () => { + const keyPair = await generateKeyPair('RS256'); + privateKey = keyPair.privateKey; + const publicJwk = await exportJWK(keyPair.publicKey); + publicJwk.kid = 'test-key'; + publicJwk.alg = 'RS256'; + publicJwk.use = 'sig'; + keySet = createLocalJWKSet({ keys: [publicJwk] }); +}); + +async function signToken(overrides: Record = {}): Promise { + return new SignJWT({ email: 'admin@example.com', ...overrides }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer(issuer) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); +} + +describe('normalizeAccessTeamDomain', () => { + it('normalizes a valid Cloudflare Access team hostname', () => { + expect(normalizeAccessTeamDomain('example.cloudflareaccess.com')).toBe(issuer); + }); + + it.each([ + 'http://example.cloudflareaccess.com', + 'https://example.com', + 'https://example.cloudflareaccess.com/path', + 'https://example.cloudflareaccess.com.evil.example' + ])('rejects an invalid team domain: %s', (value) => { + expect(() => normalizeAccessTeamDomain(value)).toThrow(); + }); +}); + +describe('verifyAccessJwt', () => { + it('returns the verified email claim', async () => { + await expect(verifyAccessJwt(await signToken(), issuer, audience, keySet)).resolves.toBe( + 'admin@example.com' + ); + }); + + it('rejects unsigned and malformed tokens', async () => { + const unsignedPayload = btoa(JSON.stringify({ email: 'attacker@example.com' })); + await expect( + verifyAccessJwt(`x.${unsignedPayload}.x`, issuer, audience, keySet) + ).rejects.toThrow(); + }); + + it('rejects a token for another audience', async () => { + await expect( + verifyAccessJwt(await signToken(), issuer, 'another-audience', keySet) + ).rejects.toThrow(); + }); + + it('requires a non-empty email claim', async () => { + await expect( + verifyAccessJwt(await signToken({ email: '' }), issuer, audience, keySet) + ).rejects.toThrow(); + }); +}); diff --git a/src/lib/server/access.ts b/src/lib/server/access.ts new file mode 100644 index 0000000..d12e62b --- /dev/null +++ b/src/lib/server/access.ts @@ -0,0 +1,63 @@ +import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from 'jose'; + +const ACCESS_HOST_SUFFIX = '.cloudflareaccess.com'; +const remoteKeySets = new Map>(); + +export function normalizeAccessTeamDomain(value: string): string { + const raw = value.trim(); + if (!raw) throw new Error('Cloudflare Access team domain is empty'); + + const url = new URL(raw.includes('://') ? raw : `https://${raw}`); + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.port || + (url.pathname !== '/' && url.pathname !== '') || + url.search || + url.hash || + !url.hostname.endsWith(ACCESS_HOST_SUFFIX) + ) { + throw new Error('Cloudflare Access team domain must be an HTTPS cloudflareaccess.com origin'); + } + + return url.origin; +} + +function getRemoteKeySet(issuer: string): ReturnType { + let keySet = remoteKeySets.get(issuer); + if (!keySet) { + keySet = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`), { + timeoutDuration: 5_000, + cooldownDuration: 30_000, + cacheMaxAge: 10 * 60_000 + }); + remoteKeySets.set(issuer, keySet); + } + return keySet; +} + +export async function verifyAccessJwt( + token: string, + teamDomain: string, + audience: string, + keySet?: JWTVerifyGetKey +): Promise { + const issuer = normalizeAccessTeamDomain(teamDomain); + const expectedAudience = audience.trim(); + if (!expectedAudience) throw new Error('Cloudflare Access audience is empty'); + + const { payload } = await jwtVerify(token, keySet ?? getRemoteKeySet(issuer), { + algorithms: ['RS256'], + issuer, + audience: expectedAudience, + clockTolerance: 5, + requiredClaims: ['iss', 'aud', 'exp', 'email'] + }); + + if (typeof payload.email !== 'string' || !payload.email.trim()) { + throw new Error('Cloudflare Access JWT email claim is invalid'); + } + + return payload.email.trim(); +}