import { createHash, createPublicKey, verify } from "node:crypto";
import { ModelError } from "./model-runtime";
let cached: { issuer: string; expires: number; keys: JsonWebKey[] } | undefined;
export async function accessOwner(
request: Request,
env = process.env
): Promise<string> {
const issuer = env.CONTEXT_ACCESS_ISSUER || "";
const audience = env.CONTEXT_ACCESS_AUD || "";
if (
!/^https:\/\/[a-z0-9-]+\.cloudflareaccess\.com$/.test(issuer) ||
!audience
)
throw new ModelError("Reviewer access is not configured yet.", 503);
const token = request.headers.get("cf-access-jwt-assertion") || "";
try {
if (!token || token.length > 16000) throw new Error();
const parts = token.split(".");
if (parts.length !== 3 || !parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p)))
throw new Error();
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString());
const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString());
if (header.alg !== "RS256" || typeof header.kid !== "string" || header.crit)
throw new Error();
const now = Date.now();
if (
!cached ||
cached.issuer !== issuer ||
cached.expires <= now ||
!cached.keys.some(
(k) => (k as JsonWebKey & { kid: string }).kid === header.kid
)
) {
const response = await fetch(issuer + "/cdn-cgi/access/certs", {
signal: AbortSignal.timeout(5000),
redirect: "error",
});
if (!response.ok)
throw new ModelError(
"Unable to verify reviewer sign-in. Retry shortly.",
503
);
const value = await response.json();
if (!Array.isArray(value.keys) || value.keys.length > 20)
throw new Error();
cached = { issuer, expires: now + 300000, keys: value.keys };
}
const key = cached.keys.find(
(k) => (k as JsonWebKey & { kid: string }).kid === header.kid
);
if (!key || key.kty !== "RSA") throw new Error();
const valid = verify(
"RSA-SHA256",
Buffer.from(parts[0] + "." + parts[1]),
createPublicKey({
key: key as import("node:crypto").JsonWebKey,
format: "jwk",
}),
Buffer.from(parts[2], "base64url")
);
if (
!valid ||
claims.iss !== issuer ||
!Array.isArray(claims.aud) ||
!claims.aud.includes(audience) ||
typeof claims.exp !== "number" ||
claims.exp * 1000 <= now ||
(claims.nbf !== undefined &&
(typeof claims.nbf !== "number" || claims.nbf * 1000 > now + 30000)) ||
typeof claims.sub !== "string" ||
!claims.sub ||
typeof claims.email !== "string"
)
throw new Error();
return createHash("sha256")
.update(issuer + ":" + claims.sub)
.digest("hex");
} catch (error) {
if (error instanceof ModelError) throw error;
throw new ModelError(
"Reviewer sign-in required. Open https://context.marmar.life and sign in again; saved work remains in this browser.",
401
);
}
}