export interface AccessEnv {
ACCESS_TEAM_DOMAIN?: string;
ACCESS_AUD?: string;
}
export interface TeamCertKey {
kty: string;
kid?: string;
use?: string;
alg?: string;
n?: string;
e?: string;
}
interface AccessClaims {
iss: string;
aud: string | string[];
exp: number;
iat: number;
email?: string;
sub?: string;
}
const CLOCK_SKEW_MS = 60 * 1000;
const b64urlBytes = (part: string): Uint8Array => {
const padded = part
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(part.length + ((4 - (part.length % 4)) % 4), '=');
const binary = atob(padded);
return Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
};
export class VerifiedAccessIdentity {
readonly #claims: Readonly<{ email: string }>;
private constructor(claims: { email: string }) {
this.#claims = Object.freeze({ email: claims.email });
}
get email(): string {
return this.#claims.email;
}
static async verify(token: string | null, env: AccessEnv): Promise<VerifiedAccessIdentity | null> {
if (!token || !env.ACCESS_TEAM_DOMAIN || !env.ACCESS_AUD) return null;
const [head, payload, signature] = token.split('.');
if (!head || !payload || !signature) return null;
try {
const header = JSON.parse(new TextDecoder().decode(b64urlBytes(head))) as {
alg?: string;
kid?: string;
};
if (header.alg !== 'RS256' || !header.kid) return null;
const certsResponse = await fetch(`https://${env.ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs`);
if (!certsResponse.ok) throw new Error(`certs endpoint returned ${certsResponse.status}`);
const certs = (await certsResponse.json()) as { keys: TeamCertKey[] };
const key = certs.keys.find((k) => k.kid === header.kid);
if (!key) return null;
const cryptoKey = await crypto.subtle.importKey(
'jwk',
key,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify'],
);
const signed = new TextEncoder().encode(`${head}.${payload}`);
const valid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
cryptoKey,
b64urlBytes(signature) as unknown as ArrayBuffer,
signed as unknown as ArrayBuffer,
);
if (!valid) return null;
const claims = JSON.parse(new TextDecoder().decode(b64urlBytes(payload))) as AccessClaims;
const now = Date.now();
if (claims.iss !== `https://${env.ACCESS_TEAM_DOMAIN}`) return null;
const audOk = Array.isArray(claims.aud)
? claims.aud.includes(env.ACCESS_AUD)
: claims.aud === env.ACCESS_AUD;
if (!audOk) return null;
if (typeof claims.exp !== 'number' || typeof claims.iat !== 'number') return null;
if (claims.exp * 1000 <= now - CLOCK_SKEW_MS) return null;
if (claims.iat * 1000 > now + CLOCK_SKEW_MS) return null;
return new VerifiedAccessIdentity({
email: claims.email ?? claims.sub ?? 'access user',
});
} catch {
return null;
}
}
}