feat: 引入 Cloudflare Access JWT 安全验证机制

This commit is contained in:
seahi committed 2026-08-07 12:53:42 +08:00
1 parent b565f9bcda
commit 964efa2cad
3 files changed
+182 -31

No files matched your search

+49 -31
View File
@@ -1,41 +1,59 @@
import type { Handle } from '@sveltejs/kit';
import { dev } from '$app/environment'; 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 }) => { export const handle: Handle = async ({ event, resolve }) => {
// 拦截 /admin 路由 if (!event.url.pathname.startsWith('/admin')) {
if (event.url.pathname.startsWith('/admin')) { return resolve(event);
// 优先尝试读取常规 Header (旧版或回源时存在)
let userEmail = event.request.headers.get('cf-access-authenticated-user-email');
// 针对 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 变量来判断 if (dev) {
const isLocalDev = dev;
if (!userEmail && !isLocalDev) {
return new Response('Unauthorized', { status: 401 });
}
// 将用户信息存入 locals,供后续使用
event.locals.user = { 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); return resolve(event);
+70
View File
@@ -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<typeof createLocalJWKSet>;
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<string, unknown> = {}): Promise<string> {
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();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from 'jose';
const ACCESS_HOST_SUFFIX = '.cloudflareaccess.com';
const remoteKeySets = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
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<typeof createRemoteJWKSet> {
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<string> {
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();
}