Files
blogflare/src/lib/server/access.ts
T

64 lines
1.8 KiB
TypeScript

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();
}