Fork Horse repository, listed by its owner. Publisher snapshot.

packages/forkhorse-core/src/access.ts

Snapshot at 34e3175

Containing directory

2,390 bytes · typescriptView raw
1import { createRemoteJWKSet, jwtVerify } from "jose";
2import type { Env } from "./types";
3import { HttpError } from "./http";
4
5const keySets = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
6
7export function accessSettings(env: Env) {
8  const issuer = env.CORE_ACCESS_ISSUER;
9  const audience = env.CORE_ACCESS_AUDIENCE;
10  const owners = env.CORE_ACCESS_OWNERS;
11  // Only the configured Access team can supply keys; JWT jku/x5u headers are never followed.
12  if (
13    !issuer ||
14    !/^https:\/\/[a-z0-9-]+\.cloudflareaccess\.com$/.test(issuer) ||
15    !audience?.trim() ||
16    audience !== audience.trim() ||
17    !Array.isArray(owners) ||
18    !owners.length ||
19    !owners.every((email) => typeof email === "string" && /^[^@\s]+@[^@\s]+$/.test(email))
20  ) {
21    throw new HttpError(503, "Owner Access configuration is incomplete.");
22  }
23  return { issuer, audience, owners: owners.map((email) => email.toLowerCase()) };
24}
25
26/** Trust only a signed, current Access application JWT and an explicitly allowed owner. */
27export async function verifyOwner(request: Request, env: Env): Promise<{ subject: string } | null> {
28  const { issuer, audience, owners } = accessSettings(env);
29  // /projects also serves bearer agents, so it is not behind an interactive edge login.
30  // Same-origin owner requests carry the Access application cookie; verify that JWT identically.
31  const cookie = request.headers.get("cookie") ?? "";
32  const token =
33    request.headers.get("cf-access-jwt-assertion") ??
34    (cookie.length <= 32768 ? /(?:^|;\s*)CF_Authorization=([^;]+)/.exec(cookie)?.[1] : undefined);
35  if (!token || token.length > 16384) return null;
36  let keys = keySets.get(issuer);
37  if (!keys) {
38    keys = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`), {
39      timeoutDuration: 5000,
40      cooldownDuration: 30000,
41      cacheMaxAge: 300000,
42    });
43    keySets.set(issuer, keys);
44  }
45  try {
46    const { payload } = await jwtVerify(token, keys, {
47      issuer,
48      audience,
49      algorithms: ["RS256"],
50      requiredClaims: ["sub", "email", "exp", "iat"],
51    });
52    if (
53      typeof payload.sub !== "string" ||
54      !payload.sub ||
55      typeof payload.email !== "string" ||
56      !owners.includes(payload.email.toLowerCase()) ||
57      payload.iat! > Date.now() / 1000
58    )
59      return null;
60    return { subject: payload.sub };
61  } catch {
62    return null;
63  }
64}
65