marmar-context / web / context / server / access.ts
access.ts
Raw
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
    );
  }
}