@homeflare/cloudflare 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-auth/handler.d.ts +65 -0
- package/dist/access-auth/handler.d.ts.map +1 -0
- package/dist/access-auth/index.d.ts +23 -0
- package/dist/access-auth/index.d.ts.map +1 -0
- package/dist/access-auth/index.js +186 -0
- package/dist/access-auth/index.js.map +14 -0
- package/dist/access-auth/token.d.ts +47 -0
- package/dist/access-auth/token.d.ts.map +1 -0
- package/dist/access.d.ts +24 -0
- package/dist/access.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +4 -4
- package/docs/access-auth.md +139 -0
- package/package.json +6 -1
- package/src/access-auth/handler.ts +195 -0
- package/src/access-auth/index.ts +34 -0
- package/src/access-auth/token.ts +73 -0
- package/src/access.ts +36 -0
- package/src/index.ts +13 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Cloudflare Access verifier shaped for Caddy's `forward_auth`.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHAT THIS ADDS, AND WHAT IT DELIBERATELY DOES NOT. The verification is
|
|
5
|
+
* `verifyAccessToken` in `../access.ts` — jose, a cached remote JWKS, a circuit breaker.
|
|
6
|
+
* This file is the HTTP shape around it: find the token, answer 2xx or 4xx, and hand the
|
|
7
|
+
* identity to the upstream as headers. There is no second verifier here, on purpose; two
|
|
8
|
+
* implementations of a signature check is how one of them silently stops checking.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ FAIL CLOSED, ALWAYS. Every path that does not end in a verified identity returns a
|
|
11
|
+
* non-2xx, including a JWKS endpoint that is down. Caddy grants access on 2xx and only
|
|
12
|
+
* on 2xx, so an exception escaping this handler would become a 502 from Caddy — still
|
|
13
|
+
* closed, but without a log line. Hence the catch-all.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ NEVER LOG THE TOKEN. Not at debug, not in an error message, not truncated. A token in
|
|
16
|
+
* a log file is a replayable session for the whole of its `exp`. `reason` below is a
|
|
17
|
+
* fixed enum and the caught error contributes only its CLASS NAME — jose's messages are
|
|
18
|
+
* safe today, but "safe today" is not a property worth betting a credential on.
|
|
19
|
+
*
|
|
20
|
+
* Guide, and the Caddyfile that calls this: docs/access-auth.md.
|
|
21
|
+
*/
|
|
22
|
+
import { type AccessIdentity, type AccessOptions } from '../access.ts';
|
|
23
|
+
import { type Logger } from '../log.ts';
|
|
24
|
+
/** Why a request was refused. A closed set, so it is safe to log and safe to alert on. */
|
|
25
|
+
export type DenyReason = 'no-token' | 'invalid-token' | 'jwks-unavailable' | 'not-authorized' | 'unexpected';
|
|
26
|
+
/** The headers the upstream receives on success. Values are always strings, never absent. */
|
|
27
|
+
export interface IdentityHeaders {
|
|
28
|
+
/** @default 'X-Access-Email' */
|
|
29
|
+
readonly email?: string;
|
|
30
|
+
/** @default 'X-Access-Sub' */
|
|
31
|
+
readonly sub?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface AccessForwardAuthOptions extends AccessOptions {
|
|
34
|
+
/** Rename the identity headers to match an upstream that expects its own names. */
|
|
35
|
+
readonly headers?: IdentityHeaders;
|
|
36
|
+
/**
|
|
37
|
+
* An extra check on an already-verified identity. `false` is a 403, not a 401.
|
|
38
|
+
* ⚠️ AUTHORIZATION, NOT AUTHENTICATION. The Access policy is the real gate; this is for
|
|
39
|
+
* an upstream that additionally wants, say, one team. Do not use it to re-check `aud`.
|
|
40
|
+
*/
|
|
41
|
+
readonly authorize?: (identity: AccessIdentity) => boolean | Promise<boolean>;
|
|
42
|
+
/** @default the package logger */
|
|
43
|
+
readonly logger?: Logger;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Build the `fetch` handler Caddy's `forward_auth` calls.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* const verify = accessForwardAuth({
|
|
50
|
+
* teamDomain: 'https://example.cloudflareaccess.com',
|
|
51
|
+
* audience: process.env.ACCESS_AUD,
|
|
52
|
+
* });
|
|
53
|
+
* Bun.serve({ port: 9101, hostname: '127.0.0.1', fetch: verify });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* ⛔ ONE HANDLER PER ACCESS APPLICATION. `audience` is fixed at construction and is never
|
|
57
|
+
* read from the request — an AUD a caller can influence is the whole vulnerability this
|
|
58
|
+
* exists to prevent. Protecting two applications means two handlers and two routes.
|
|
59
|
+
*
|
|
60
|
+
* ★ RETURNS A PLAIN `(Request) => Promise<Response>`, so it needs no server framework and
|
|
61
|
+
* no runtime-specific import: `Bun.serve`, `node:http`'s fetch adapters and workerd all
|
|
62
|
+
* take it as-is. Routing several of these by path is the consumer's own `if`.
|
|
63
|
+
*/
|
|
64
|
+
export declare function accessForwardAuth(options: AccessForwardAuthOptions): (request: Request) => Promise<Response>;
|
|
65
|
+
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/access-auth/handler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAqB,MAAM,cAAc,CAAC;AAC1F,OAAO,EAAE,KAAK,MAAM,EAAO,MAAM,WAAW,CAAC;AAG7C,0FAA0F;AAC1F,MAAM,MAAM,UAAU,GAClB,UAAU,GACV,eAAe,GACf,kBAAkB,GAClB,gBAAgB,GAChB,YAAY,CAAC;AAEjB,6FAA6F;AAC7F,MAAM,WAAW,eAAe;IAC9B,gCAAgC;IAChC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,8BAA8B;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,wBAAyB,SAAQ,aAAa;IAC7D,mFAAmF;IACnF,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;IACnC;;;;OAIG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,cAAc,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC9E,kCAAkC;IAClC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAuCD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,wBAAwB,GAChC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA6CzC"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@homeflare/cloudflare/access-auth` — a Cloudflare Access verifier for a reverse proxy's
|
|
3
|
+
* `forward_auth`, so a surface that is not a Worker can be protected by Access properly.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY THIS IS A SUBPATH AND NOT THE MAIN ENTRY. The package entry is the workerd layer.
|
|
6
|
+
* This handler is runtime-neutral — `Request` in, `Response` out, no platform import —
|
|
7
|
+
* because the process that runs it sits next to the proxy, typically under Bun or Node,
|
|
8
|
+
* not on workerd. A subpath keeps that opt-in explicit and leaves the entry unchanged.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ THE AUD IS THE WHOLE CHECK. Every Access application in one account is signed by the
|
|
11
|
+
* SAME team keys, so a token minted for a different app in your own account verifies
|
|
12
|
+
* perfectly against issuer and signature. Only `audience` says the token was minted for
|
|
13
|
+
* THIS application. A verifier that checks `iss` alone admits every Access user of every
|
|
14
|
+
* app you run — which reads as working, for everyone, until the wrong person tries.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ THIS PROTECTS THE PROXY HOP, NOT THE ORIGIN PORT. Verifying the JWT is worth nothing
|
|
17
|
+
* if the upstream is also reachable directly; bind it to loopback, or the check is an
|
|
18
|
+
* ornament on a door that is propped open. docs/access-auth.md says it again with the
|
|
19
|
+
* Caddyfile.
|
|
20
|
+
*/
|
|
21
|
+
export { accessForwardAuth, type AccessForwardAuthOptions, type DenyReason, type IdentityHeaders, } from './handler.ts';
|
|
22
|
+
export { ACCESS_COOKIE, ACCESS_HEADER, accessToken, readCookie, type FoundToken, type TokenSource, } from './token.ts';
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/access-auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EACL,iBAAiB,EACjB,KAAK,wBAAwB,EAC7B,KAAK,UAAU,EACf,KAAK,eAAe,GACrB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,aAAa,EACb,aAAa,EACb,WAAW,EACX,UAAU,EACV,KAAK,UAAU,EACf,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// src/access.ts
|
|
2
|
+
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
3
|
+
|
|
4
|
+
// src/jwks-breaker.ts
|
|
5
|
+
var BREAKER_COOLDOWN_MS = 30000;
|
|
6
|
+
var failures = new Map;
|
|
7
|
+
function breakered(inner = (url, options) => fetch(url, options), cooldownMs = BREAKER_COOLDOWN_MS) {
|
|
8
|
+
return async (url, options) => {
|
|
9
|
+
const open = failures.get(url);
|
|
10
|
+
if (open !== undefined && Date.now() - open.at < cooldownMs) {
|
|
11
|
+
throw open.error;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const response = await inner(url, options);
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
const error = new Error(`JWKS endpoint answered ${String(response.status)}: ${url}`);
|
|
17
|
+
failures.set(url, { at: Date.now(), error });
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
failures.delete(url);
|
|
21
|
+
return response;
|
|
22
|
+
} catch (cause) {
|
|
23
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
24
|
+
failures.set(url, { at: Date.now(), error });
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/access.ts
|
|
31
|
+
var ACCESS_HEADER = "cf-access-jwt-assertion";
|
|
32
|
+
var jwks = new Map;
|
|
33
|
+
function keysFor(teamDomain) {
|
|
34
|
+
const existing = jwks.get(teamDomain);
|
|
35
|
+
if (existing !== undefined)
|
|
36
|
+
return existing;
|
|
37
|
+
const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`), {
|
|
38
|
+
[customFetch]: breakered()
|
|
39
|
+
});
|
|
40
|
+
jwks.set(teamDomain, created);
|
|
41
|
+
return created;
|
|
42
|
+
}
|
|
43
|
+
async function verifyAccessJwt(request, options) {
|
|
44
|
+
const token = request.headers.get(ACCESS_HEADER);
|
|
45
|
+
if (token === null)
|
|
46
|
+
throw new Error("access: no Cf-Access-Jwt-Assertion header");
|
|
47
|
+
return await verifyAccessToken(token, options);
|
|
48
|
+
}
|
|
49
|
+
async function verifyAccessToken(token, options) {
|
|
50
|
+
const { payload } = await jwtVerify(token, keysFor(options.teamDomain), {
|
|
51
|
+
issuer: options.teamDomain,
|
|
52
|
+
audience: options.audience,
|
|
53
|
+
...options.maxTokenAge === undefined ? {} : { maxTokenAge: options.maxTokenAge },
|
|
54
|
+
...options.clockTolerance === undefined ? {} : { clockTolerance: options.clockTolerance }
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
email: typeof payload["email"] === "string" ? payload["email"] : undefined,
|
|
58
|
+
sub: payload.sub,
|
|
59
|
+
claims: payload
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/log.ts
|
|
64
|
+
function emit(level, message, fields) {
|
|
65
|
+
const line = { level, message, ...fields };
|
|
66
|
+
if (level === "error")
|
|
67
|
+
console.error(line);
|
|
68
|
+
else if (level === "warn")
|
|
69
|
+
console.warn(line);
|
|
70
|
+
else
|
|
71
|
+
console.log(line);
|
|
72
|
+
}
|
|
73
|
+
function make(bound) {
|
|
74
|
+
return {
|
|
75
|
+
debug: (message, fields) => emit("debug", message, { ...bound, ...fields }),
|
|
76
|
+
info: (message, fields) => emit("info", message, { ...bound, ...fields }),
|
|
77
|
+
warn: (message, fields) => emit("warn", message, { ...bound, ...fields }),
|
|
78
|
+
error: (message, fields) => emit("error", message, { ...bound, ...fields }),
|
|
79
|
+
with: (extra) => make({ ...bound, ...extra })
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
var log = make({});
|
|
83
|
+
|
|
84
|
+
// src/access-auth/token.ts
|
|
85
|
+
var ACCESS_HEADER2 = "Cf-Access-Jwt-Assertion";
|
|
86
|
+
var ACCESS_COOKIE = "CF_Authorization";
|
|
87
|
+
function readCookie(header, name) {
|
|
88
|
+
if (header === null)
|
|
89
|
+
return;
|
|
90
|
+
for (const pair of header.split(";")) {
|
|
91
|
+
const eq = pair.indexOf("=");
|
|
92
|
+
if (eq === -1)
|
|
93
|
+
continue;
|
|
94
|
+
if (pair.slice(0, eq).trim() !== name)
|
|
95
|
+
continue;
|
|
96
|
+
const value = pair.slice(eq + 1).trim();
|
|
97
|
+
return value === "" ? undefined : value;
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
function accessToken(request) {
|
|
102
|
+
const fromHeader = request.headers.get(ACCESS_HEADER2);
|
|
103
|
+
if (fromHeader !== null && fromHeader !== "")
|
|
104
|
+
return { token: fromHeader, source: "header" };
|
|
105
|
+
const fromCookie = readCookie(request.headers.get("Cookie"), ACCESS_COOKIE);
|
|
106
|
+
if (fromCookie !== undefined)
|
|
107
|
+
return { token: fromCookie, source: "cookie" };
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/access-auth/handler.ts
|
|
112
|
+
var DEFAULT_HEADERS = { email: "X-Access-Email", sub: "X-Access-Sub" };
|
|
113
|
+
function reasonFor(error) {
|
|
114
|
+
for (let cur = error, hops = 0;cur instanceof Error && hops < 4; hops += 1) {
|
|
115
|
+
if (cur.message.includes("JWKS endpoint answered"))
|
|
116
|
+
return "jwks-unavailable";
|
|
117
|
+
if (cur.name === "JWKSTimeout" || cur.name === "TypeError")
|
|
118
|
+
return "jwks-unavailable";
|
|
119
|
+
cur = cur.cause;
|
|
120
|
+
}
|
|
121
|
+
return "invalid-token";
|
|
122
|
+
}
|
|
123
|
+
function errorClass(error) {
|
|
124
|
+
return error instanceof Error ? error.name : typeof error;
|
|
125
|
+
}
|
|
126
|
+
function deny(status) {
|
|
127
|
+
return new Response(null, { status });
|
|
128
|
+
}
|
|
129
|
+
function accessForwardAuth(options) {
|
|
130
|
+
const names = { ...DEFAULT_HEADERS, ...options.headers };
|
|
131
|
+
const logger = options.logger ?? log;
|
|
132
|
+
assertTeamDomain(options.teamDomain);
|
|
133
|
+
return async (request) => {
|
|
134
|
+
const found = accessToken(request);
|
|
135
|
+
if (found === undefined) {
|
|
136
|
+
logger.warn("access denied", { reason: "no-token" });
|
|
137
|
+
return deny(401);
|
|
138
|
+
}
|
|
139
|
+
let identity;
|
|
140
|
+
try {
|
|
141
|
+
identity = await verifyAccessToken(found.token, options);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const reason = reasonFor(error);
|
|
144
|
+
logger.warn("access denied", { reason, source: found.source, error: errorClass(error) });
|
|
145
|
+
return deny(401);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
if (options.authorize !== undefined && !await options.authorize(identity)) {
|
|
149
|
+
logger.warn("access denied", {
|
|
150
|
+
reason: "not-authorized",
|
|
151
|
+
email: identity.email ?? ""
|
|
152
|
+
});
|
|
153
|
+
return deny(403);
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
logger.error("access denied", {
|
|
157
|
+
reason: "unexpected",
|
|
158
|
+
error: errorClass(error)
|
|
159
|
+
});
|
|
160
|
+
return deny(403);
|
|
161
|
+
}
|
|
162
|
+
return granted(identity, names, found.source);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function granted(identity, names, source) {
|
|
166
|
+
const headers = new Headers;
|
|
167
|
+
headers.set(names.email, identity.email ?? "");
|
|
168
|
+
headers.set(names.sub, identity.sub ?? "");
|
|
169
|
+
headers.set("X-Access-Token-Source", source);
|
|
170
|
+
return new Response(null, { status: 204, headers });
|
|
171
|
+
}
|
|
172
|
+
function assertTeamDomain(teamDomain) {
|
|
173
|
+
if (!teamDomain.startsWith("https://") || teamDomain.endsWith("/")) {
|
|
174
|
+
throw new Error(`access-auth: teamDomain must be https:// with no trailing slash, got ${teamDomain}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
ACCESS_COOKIE,
|
|
179
|
+
ACCESS_HEADER2 as ACCESS_HEADER,
|
|
180
|
+
accessForwardAuth,
|
|
181
|
+
accessToken,
|
|
182
|
+
readCookie
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
//# debugId=5270BF136297A56A64756E2164756E21
|
|
186
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/access.ts", "../src/jwks-breaker.ts", "../src/log.ts", "../src/access-auth/token.ts", "../src/access-auth/handler.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * Cloudflare Access JWT verification.\n *\n * ★ WHY jose AND NOT A HAND-ROLLED VERIFY. Signature verification is the one place where\n * a subtle bug is silent and total: a check that never fails looks identical to a\n * check that always passes. jose handles JWKS fetching, `kid` matching and key\n * rotation, and it is widely deployed. This file is the Access-specific\n * wiring around it, nothing more.\n *\n * ⚠️ THE TRAP THIS CLOSES. Cloudflare's own example does `jwtVerify(...)` inline in the\n * handler, which builds a NEW remote JWKS on every request — an outbound fetch per\n * request, and a thundering herd on the certs endpoint under load. The cache below is\n * keyed by team domain and lives for the isolate's lifetime.\n *\n * ⛔ NEVER SKIP `audience`. Any Access app in your account produces a JWT this team\n * domain will happily verify; the AUD tag is the only thing that says the token was\n * minted for THIS app. Verifying issuer alone accepts tokens from a neighbouring app.\n *\n * Docs: developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/\n */\nimport { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';\nimport { breakered } from './jwks-breaker.ts';\n\n/** The header Access puts the token in. Lowercase — Headers.get is case-insensitive. */\nconst ACCESS_HEADER = 'cf-access-jwt-assertion';\n\nexport interface AccessOptions {\n /** `https://<team>.cloudflareaccess.com` — the issuer, and the JWKS host. */\n readonly teamDomain: string;\n /** The application's AUD tag, from Zero Trust → Access → Applications. */\n readonly audience: string;\n /**\n * Reject a token older than this, by `iat`. Seconds, or a jose duration like `'8h'`.\n * ★ OPT-IN BECAUSE `exp` ALREADY BOUNDS THE TOKEN. Access sets the session lifetime in\n * `exp`, so this is a second, shorter leash for a surface that wants one — not a\n * missing check. ⚠️ Setting it below the Access session duration logs users out\n * mid-session with a 401 they cannot fix by retrying.\n */\n readonly maxTokenAge?: string | number;\n /** Seconds of clock skew tolerated on `exp`/`nbf`/`iat`. jose's default is 0. */\n readonly clockTolerance?: string | number;\n}\n\n/** The subset of Access claims worth depending on. */\nexport interface AccessIdentity {\n readonly email: string | undefined;\n readonly sub: string | undefined;\n /** Everything else, for callers that need a claim this interface does not name. */\n readonly claims: Readonly<Record<string, unknown>>;\n}\n\n/**\n * One JWKS per team domain, reused for the isolate's life.\n * ⚠️ Module scope is per-isolate on workerd, not per-request and not global. That is\n * exactly the right lifetime here: long enough to amortise the fetch, short enough\n * that a rotated key is picked up when the isolate recycles. jose also refreshes on\n * an unknown `kid`.\n */\nconst jwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\nfunction keysFor(teamDomain: string): ReturnType<typeof createRemoteJWKSet> {\n const existing = jwks.get(teamDomain);\n if (existing !== undefined) return existing;\n\n // ⛔ THE BREAKER IS NOT OPTIONAL. jose refetches on EVERY call while an endpoint is\n // down — measured on 6.2.12: 5 verifications against a 404ing certs endpoint made 5\n // outbound fetches, and no combination of cacheMaxAge or cooldownDuration changes it.\n // Without this, a Cloudflare Access outage turns every inbound request into an\n // outbound one. See jwks-breaker.ts.\n const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`), {\n [customFetch]: breakered(),\n });\n jwks.set(teamDomain, created);\n return created;\n}\n\n/**\n * Verify the Access JWT on `request`, returning the identity it asserts.\n *\n * ⛔ THROWS ON EVERY FAILURE — missing header, bad signature, wrong audience, expired.\n * ★ Deliberately not a boolean or a null: an `if (!ok)` that a caller forgets to write\n * is an open door, whereas an unhandled throw is a 500 and a log line. Callers that\n * want a soft path catch it explicitly.\n */\nexport async function verifyAccessJwt(\n request: Request,\n options: AccessOptions,\n): Promise<AccessIdentity> {\n const token = request.headers.get(ACCESS_HEADER);\n if (token === null) throw new Error('access: no Cf-Access-Jwt-Assertion header');\n\n return await verifyAccessToken(token, options);\n}\n\n/**\n * Verify a token string that the caller has already located, returning the identity.\n *\n * ★ EXTRACTED SO THERE IS EXACTLY ONE VERIFICATION, NOT TWO. `verifyAccessJwt` reads the\n * header; the forward_auth handler (`@homeflare/cloudflare/access-auth`) also accepts\n * the `CF_Authorization` cookie, because Cloudflare's own documentation says the cookie\n * \"is not guaranteed to be passed\" and the reverse is true too — a request that arrives\n * through a proxy hop may carry one and not the other. Both funnel here, so a fix to the\n * checks below cannot land on one path and miss the other.\n *\n * ⛔ THROWS ON EVERY FAILURE, exactly like `verifyAccessJwt`. Same reasoning: a boolean a\n * caller forgets to test is an open door.\n */\nexport async function verifyAccessToken(\n token: string,\n options: AccessOptions,\n): Promise<AccessIdentity> {\n const { payload } = await jwtVerify(token, keysFor(options.teamDomain), {\n issuer: options.teamDomain,\n audience: options.audience,\n // ⛔ SPREAD, NOT `maxTokenAge: options.maxTokenAge`. `exactOptionalPropertyTypes` is on,\n // and passing an explicit `undefined` is not the same as omitting the key — jose\n // reads `'maxTokenAge' in options`, so the undefined form would enable the check\n // with an undefined bound.\n ...(options.maxTokenAge === undefined ? {} : { maxTokenAge: options.maxTokenAge }),\n ...(options.clockTolerance === undefined ? {} : { clockTolerance: options.clockTolerance }),\n });\n\n return {\n email: typeof payload['email'] === 'string' ? payload['email'] : undefined,\n sub: payload.sub,\n claims: payload,\n };\n}\n",
|
|
6
|
+
"/**\n * A circuit breaker for JWKS fetches, installed at jose's own `customFetch` seam.\n *\n * ⛔ THE AMPLIFICATION THIS CLOSES, MEASURED ON jose 6.2.12 — the version this package\n * pins, not an old one:\n *\n * 5 verifications of a well-formed token, certs endpoint 404ing → 5 outbound fetches\n *\n * jose assigns `jwksTimestamp` only inside the `.then()` of a SUCCESSFUL fetch\n * (`dist/webapi/jwks/remote.js`, line 58). While an endpoint is down, `local` stays\n * undefined, so `if (!local || !isFreshFor(jwksTimestamp, cacheMaxAge)) await reload()`\n * runs on EVERY call. A down Access endpoint therefore turns every inbound request into\n * an outbound fetch, indefinitely.\n *\n * ★ NOT A REIMPLEMENTATION. `customFetch` is jose's exported, documented extension point;\n * jose keeps doing the caching, cooldown, `kid` matching and verification. This adds the\n * one thing it does not do — remember that a fetch FAILED.\n * ⚠️ Measured first: neither `cacheMaxAge: Infinity` nor `cooldownDuration` nor both\n * together change the count. It is 5 fetches in every configuration, so this is not a\n * matter of configuring jose correctly.\n *\n * ⛔ THE BREAKER STORES PLAIN VALUES ONLY — a timestamp and an Error. NEVER a Response, a\n * body stream, or a promise of one.\n * 🔴 On workerd, a Response and its body belong to the IoContext of the request that\n * created them, and another request touching either throws:\n * Cannot perform I/O on behalf of a different request.\n * Caching a Response across requests would therefore turn a VALID token into a flat\n * 401 for every caller but the first — an outage strictly worse than the amplification\n * it was meant to fix. Timestamps and Errors cross requests legally; I/O objects do not.\n */\n\nimport type { FetchImplementation } from 'jose';\n\n/** How long a URL stays latched open after a failed probe. */\nexport const BREAKER_COOLDOWN_MS = 30_000;\n\ntype Failure = { readonly at: number; readonly error: Error };\n\n/**\n * ⚠️ Module scope is PER ISOLATE on workerd, which is the right lifetime: long enough to\n * stop the storm, short enough that a recycled isolate retries a recovered endpoint.\n */\nconst failures = new Map<string, Failure>();\n\n/** Visible for testing. ⛔ Not exported from the package — resetting it in production\n * would discard exactly the memory this exists to keep. */\nexport function resetBreaker(): void {\n failures.clear();\n}\n\n/**\n * Wrap a fetch so a failing URL is not retried for `cooldownMs`.\n *\n * ★ FAILURE IS COUNTED AT THE SEAM, not inferred from an error class further up. jose\n * throws `JWKSNoMatchingKey` as a NORMAL answer from a perfectly healthy endpoint — a\n * token signed with a rotated key does that — so treating it as a fault would latch a\n * working endpoint closed and reject every valid token for 30 seconds.\n */\nexport function breakered(\n inner: FetchImplementation = (url, options) => fetch(url, options),\n cooldownMs: number = BREAKER_COOLDOWN_MS,\n): FetchImplementation {\n // ⚠️ jose's OWN FetchImplementation, not `typeof fetch`. It is narrower — a string URL\n // and a fixed options shape — and using the global type instead fails to typecheck on\n // `preconnect`, a property of the platform fetch that no seam implementation has.\n return async (url, options) => {\n const open = failures.get(url);\n\n if (open !== undefined && Date.now() - open.at < cooldownMs) {\n // ⚠️ Rethrow the ORIGINAL error, not a generic one: the first failure says whether\n // this was a 404, a TLS problem or a timeout, and that is what an operator needs.\n // A \"circuit open\" message alone hides the actual fault.\n throw open.error;\n }\n\n try {\n const response = await inner(url, options);\n\n // ⛔ A non-200 is a failure here. jose requires 200 with a JSON body; a 404 or a 502\n // is exactly the down-endpoint case, and it does NOT throw on its own.\n if (!response.ok) {\n const error = new Error(`JWKS endpoint answered ${String(response.status)}: ${url}`);\n failures.set(url, { at: Date.now(), error });\n throw error;\n }\n\n failures.delete(url);\n return response;\n } catch (cause) {\n const error = cause instanceof Error ? cause : new Error(String(cause));\n // `set` is idempotent here — a second failure simply restarts the cooldown.\n failures.set(url, { at: Date.now(), error });\n throw error;\n }\n };\n}\n",
|
|
7
|
+
"/**\n * Structured logging for Workers.\n *\n * ★ WHY NO LOGGING LIBRARY. Cloudflare Workers Logs parses `console.log` output as JSON\n * natively and indexes every field, with unlimited cardinality — so a JSON object on\n * console IS the structured logging integration. pino is Node-shaped (streams, worker\n * threads) and needs a wrapper on workerd; `pino-pretty` does not work with wrangler\n * at all (workers-sdk#6841). Adding a dependency here would buy formatting we do not\n * want and cost bundle size on a hot path.\n * Docs: developers.cloudflare.com/workers/observability/logs/workers-logs/\n *\n * ⛔ NEVER LOG A CREDENTIAL. There is no redaction layer here and deliberately so — a\n * redactor implies it is safe to pass secrets in, and then quietly misses one.\n */\n\n/** Anything JSON-serialisable. Workers Logs indexes each key as its own field. */\nexport type LogFields = Readonly<Record<string, unknown>>;\n\nexport interface Logger {\n readonly debug: (message: string, fields?: LogFields) => void;\n readonly info: (message: string, fields?: LogFields) => void;\n readonly warn: (message: string, fields?: LogFields) => void;\n readonly error: (message: string, fields?: LogFields) => void;\n /** Returns a logger that merges `bound` into every subsequent line. */\n readonly with: (bound: LogFields) => Logger;\n}\n\ntype Level = 'debug' | 'info' | 'warn' | 'error';\n\nfunction emit(level: Level, message: string, fields: LogFields): void {\n // ⚠️ ONE OBJECT, NOT (message, fields). console.log with several arguments is\n // stringified and concatenated by the runtime, which destroys the structure Workers\n // Logs would otherwise index — the symptom is a `message` field containing your\n // whole payload as text, unqueryable.\n const line = { level, message, ...fields };\n\n if (level === 'error') console.error(line);\n else if (level === 'warn') console.warn(line);\n else console.log(line);\n}\n\nfunction make(bound: LogFields): Logger {\n return {\n debug: (message, fields) => emit('debug', message, { ...bound, ...fields }),\n info: (message, fields) => emit('info', message, { ...bound, ...fields }),\n warn: (message, fields) => emit('warn', message, { ...bound, ...fields }),\n error: (message, fields) => emit('error', message, { ...bound, ...fields }),\n with: (extra) => make({ ...bound, ...extra }),\n };\n}\n\n/**\n * The root logger. Bind request-scoped context rather than passing it every call:\n *\n * const requestLog = log.with({ requestId, path: url.pathname });\n * requestLog.info('handled', { status, ms });\n */\nexport const log: Logger = make({});\n",
|
|
8
|
+
"/**\n * Finding the Access token on an inbound request — header first, cookie second.\n *\n * ★ BOTH, BECAUSE NEITHER IS GUARANTEED. Cloudflare's validation guide says the\n * `CF_Authorization` cookie \"is not guaranteed to be passed\" and to prefer the\n * `Cf-Access-Jwt-Assertion` header. The reverse also happens: the header is attached by\n * the Access hop, so a request that reaches the origin through a further proxy — or a\n * browser replaying its own cookie — can carry the cookie and not the header. Checking\n * one only is a 401 that looks like a broken login and is actually a missing branch.\n *\n * ⛔ THE HEADER WINS WHEN BOTH ARE PRESENT, and that order is load-bearing. The header is\n * set by the Access hop on this request; the cookie is whatever the client's jar held.\n * Preferring the cookie would let a client pin a stale token over the fresh assertion.\n * ⚠️ Neither is trusted here — both are verified identically by `verifyAccessToken`.\n * This is about which candidate is tried, not about which is believed.\n */\n\n/** The header Access sets on the request it forwards to the origin. */\nexport const ACCESS_HEADER = 'Cf-Access-Jwt-Assertion';\n\n/** The cookie Access sets in the browser. */\nexport const ACCESS_COOKIE = 'CF_Authorization';\n\n/** Where a candidate token was found. Useful in a log line; never a trust signal. */\nexport type TokenSource = 'header' | 'cookie';\n\nexport interface FoundToken {\n readonly token: string;\n readonly source: TokenSource;\n}\n\n/**\n * Read one cookie out of a `Cookie` header value.\n *\n * ⚠️ HAND-PARSED, AND SMALL ON PURPOSE. A cookie library would bring parsing of\n * attributes (Path, SameSite, Max-Age) that only ever appear on `Set-Cookie`, which is\n * the response side. Requests carry a bare `name=value; name=value` list, and that is\n * all this reads.\n * ⚠️ A JWT is base64url and contains no `;` or `=`-in-value ambiguity beyond padding, but\n * this still splits on the FIRST `=` only — a value containing `=` (base64 padding) is\n * otherwise truncated mid-token, which fails verification with a signature error and\n * sends you hunting in the wrong place.\n */\nexport function readCookie(header: string | null, name: string): string | undefined {\n if (header === null) return undefined;\n\n for (const pair of header.split(';')) {\n const eq = pair.indexOf('=');\n if (eq === -1) continue;\n if (pair.slice(0, eq).trim() !== name) continue;\n\n const value = pair.slice(eq + 1).trim();\n return value === '' ? undefined : value;\n }\n\n return undefined;\n}\n\n/**\n * The Access token on `request`, or `undefined` when it carries none.\n *\n * ⛔ RETURNS THE CANDIDATE UNVERIFIED. Every caller must hand the result to\n * `verifyAccessToken`; nothing about being present in a header makes a token valid.\n */\nexport function accessToken(request: Request): FoundToken | undefined {\n const fromHeader = request.headers.get(ACCESS_HEADER);\n if (fromHeader !== null && fromHeader !== '') return { token: fromHeader, source: 'header' };\n\n const fromCookie = readCookie(request.headers.get('Cookie'), ACCESS_COOKIE);\n if (fromCookie !== undefined) return { token: fromCookie, source: 'cookie' };\n\n return undefined;\n}\n",
|
|
9
|
+
"/**\n * A Cloudflare Access verifier shaped for Caddy's `forward_auth`.\n *\n * ★ WHAT THIS ADDS, AND WHAT IT DELIBERATELY DOES NOT. The verification is\n * `verifyAccessToken` in `../access.ts` — jose, a cached remote JWKS, a circuit breaker.\n * This file is the HTTP shape around it: find the token, answer 2xx or 4xx, and hand the\n * identity to the upstream as headers. There is no second verifier here, on purpose; two\n * implementations of a signature check is how one of them silently stops checking.\n *\n * ⛔ FAIL CLOSED, ALWAYS. Every path that does not end in a verified identity returns a\n * non-2xx, including a JWKS endpoint that is down. Caddy grants access on 2xx and only\n * on 2xx, so an exception escaping this handler would become a 502 from Caddy — still\n * closed, but without a log line. Hence the catch-all.\n *\n * ⛔ NEVER LOG THE TOKEN. Not at debug, not in an error message, not truncated. A token in\n * a log file is a replayable session for the whole of its `exp`. `reason` below is a\n * fixed enum and the caught error contributes only its CLASS NAME — jose's messages are\n * safe today, but \"safe today\" is not a property worth betting a credential on.\n *\n * Guide, and the Caddyfile that calls this: docs/access-auth.md.\n */\nimport { type AccessIdentity, type AccessOptions, verifyAccessToken } from '../access.ts';\nimport { type Logger, log } from '../log.ts';\nimport { type TokenSource, accessToken } from './token.ts';\n\n/** Why a request was refused. A closed set, so it is safe to log and safe to alert on. */\nexport type DenyReason =\n | 'no-token'\n | 'invalid-token'\n | 'jwks-unavailable'\n | 'not-authorized'\n | 'unexpected';\n\n/** The headers the upstream receives on success. Values are always strings, never absent. */\nexport interface IdentityHeaders {\n /** @default 'X-Access-Email' */\n readonly email?: string;\n /** @default 'X-Access-Sub' */\n readonly sub?: string;\n}\n\nexport interface AccessForwardAuthOptions extends AccessOptions {\n /** Rename the identity headers to match an upstream that expects its own names. */\n readonly headers?: IdentityHeaders;\n /**\n * An extra check on an already-verified identity. `false` is a 403, not a 401.\n * ⚠️ AUTHORIZATION, NOT AUTHENTICATION. The Access policy is the real gate; this is for\n * an upstream that additionally wants, say, one team. Do not use it to re-check `aud`.\n */\n readonly authorize?: (identity: AccessIdentity) => boolean | Promise<boolean>;\n /** @default the package logger */\n readonly logger?: Logger;\n}\n\nconst DEFAULT_HEADERS = { email: 'X-Access-Email', sub: 'X-Access-Sub' } as const;\n\n/**\n * ⛔ A JWKS FAILURE MUST NOT READ AS A BAD TOKEN. They need opposite responses from an\n * operator — rotate nothing versus page someone — and they are indistinguishable in the\n * caller's 401. jose surfaces the breaker's error as the `cause` of a JWKS error, so the\n * class names of both the error and its cause are checked.\n */\nfunction reasonFor(error: unknown): DenyReason {\n // ⚠️ WALK THE `cause` CHAIN. jose wraps a transport failure, so the interesting error is\n // rarely the outermost one — matching only on `error.name` reports every JWKS outage\n // as a bad token, which is the diagnosis that sends an operator to the wrong system.\n for (let cur: unknown = error, hops = 0; cur instanceof Error && hops < 4; hops += 1) {\n // The breaker in ../jwks-breaker.ts throws exactly this for a non-200 certs endpoint.\n if (cur.message.includes('JWKS endpoint answered')) return 'jwks-unavailable';\n // `JWKSTimeout` is jose's; a bare `TypeError` from the fetch seam is \"network refused\".\n if (cur.name === 'JWKSTimeout' || cur.name === 'TypeError') return 'jwks-unavailable';\n cur = cur.cause;\n }\n\n // ⛔ EVERYTHING ELSE IS A BAD TOKEN, INCLUDING `JWKSNoMatchingKey`. An unknown `kid` from\n // a healthy endpoint means the token was signed by a key this team does not publish —\n // that is a forgery or a long-rotated token, never an outage. Classing it as an outage\n // would hide a forged token in the alert that says \"Cloudflare is down\".\n return 'invalid-token';\n}\n\nfunction errorClass(error: unknown): string {\n return error instanceof Error ? error.name : typeof error;\n}\n\n/** ⛔ No body, no `WWW-Authenticate` detail, no reason. An unauthenticated caller learns\n * nothing about why; the reason goes to the log, where an operator can read it. */\nfunction deny(status: 401 | 403): Response {\n return new Response(null, { status });\n}\n\n/**\n * Build the `fetch` handler Caddy's `forward_auth` calls.\n *\n * ```ts\n * const verify = accessForwardAuth({\n * teamDomain: 'https://example.cloudflareaccess.com',\n * audience: process.env.ACCESS_AUD,\n * });\n * Bun.serve({ port: 9101, hostname: '127.0.0.1', fetch: verify });\n * ```\n *\n * ⛔ ONE HANDLER PER ACCESS APPLICATION. `audience` is fixed at construction and is never\n * read from the request — an AUD a caller can influence is the whole vulnerability this\n * exists to prevent. Protecting two applications means two handlers and two routes.\n *\n * ★ RETURNS A PLAIN `(Request) => Promise<Response>`, so it needs no server framework and\n * no runtime-specific import: `Bun.serve`, `node:http`'s fetch adapters and workerd all\n * take it as-is. Routing several of these by path is the consumer's own `if`.\n */\nexport function accessForwardAuth(\n options: AccessForwardAuthOptions,\n): (request: Request) => Promise<Response> {\n const names = { ...DEFAULT_HEADERS, ...options.headers };\n const logger = options.logger ?? log;\n assertTeamDomain(options.teamDomain);\n\n return async (request: Request): Promise<Response> => {\n const found = accessToken(request);\n if (found === undefined) {\n logger.warn('access denied', { reason: 'no-token' satisfies DenyReason });\n return deny(401);\n }\n\n let identity: AccessIdentity;\n try {\n identity = await verifyAccessToken(found.token, options);\n } catch (error) {\n const reason = reasonFor(error);\n logger.warn('access denied', { reason, source: found.source, error: errorClass(error) });\n return deny(401);\n }\n\n try {\n if (options.authorize !== undefined && !(await options.authorize(identity))) {\n // ⚠️ 403, not 401: re-authenticating cannot help, and a 401 sends the browser back\n // to Access for a login it already completed — an infinite redirect loop.\n logger.warn('access denied', {\n reason: 'not-authorized' satisfies DenyReason,\n email: identity.email ?? '',\n });\n return deny(403);\n }\n } catch (error) {\n // ⛔ A THROWING `authorize` IS A DENIAL, NOT A GRANT. A consumer's callback that hits\n // its own database and fails must not fall through to the success path; letting the\n // exception escape would hand Caddy a 502, which is also closed but arrives with no\n // log line saying whose callback broke.\n logger.error('access denied', {\n reason: 'unexpected' satisfies DenyReason,\n error: errorClass(error),\n });\n return deny(403);\n }\n\n return granted(identity, names, found.source);\n };\n}\n\n/**\n * ⛔ EVERY IDENTITY HEADER IS SET ON EVERY 2xx, empty string included. This is the\n * spoofing defence, and it is not optional.\n * 🔴 Caddy's `copy_headers` expands to `request_header <Name> {rp.header.<Name>}` inside\n * a 2xx handler — it copies FROM the auth response ONTO the client's request. A client\n * that sends `X-Access-Email: admin@example.com` itself is overwritten only if this\n * response carries that header. Omitting it for a token with no `email` claim would\n * leave the attacker's value standing, and the upstream would read it as identity.\n * Always emitting the name closes that whether or not Caddy also clears absent fields\n * — behaviour its documentation does not actually promise.\n */\nfunction granted(\n identity: AccessIdentity,\n names: Required<IdentityHeaders>,\n source: TokenSource,\n): Response {\n const headers = new Headers();\n headers.set(names.email, identity.email ?? '');\n headers.set(names.sub, identity.sub ?? '');\n headers.set('X-Access-Token-Source', source);\n return new Response(null, { status: 204, headers });\n}\n\n/**\n * ⚠️ A TRAILING SLASH BREAKS BOTH CHECKS AT ONCE, AND QUIETLY. `teamDomain` is used\n * verbatim as the expected `iss` and as the JWKS origin, so `https://x.cloudflareaccess.com/`\n * yields an `iss` that never matches any real token AND a double-slashed certs URL. The\n * symptom is every token failing verification with no clue pointing at the config.\n */\nfunction assertTeamDomain(teamDomain: string): void {\n if (!teamDomain.startsWith('https://') || teamDomain.endsWith('/')) {\n throw new Error(\n `access-auth: teamDomain must be https:// with no trailing slash, got ${teamDomain}`,\n );\n }\n}\n"
|
|
10
|
+
],
|
|
11
|
+
"mappings": "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",
|
|
12
|
+
"debugId": "5270BF136297A56A64756E2164756E21",
|
|
13
|
+
"names": []
|
|
14
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the Access token on an inbound request — header first, cookie second.
|
|
3
|
+
*
|
|
4
|
+
* ★ BOTH, BECAUSE NEITHER IS GUARANTEED. Cloudflare's validation guide says the
|
|
5
|
+
* `CF_Authorization` cookie "is not guaranteed to be passed" and to prefer the
|
|
6
|
+
* `Cf-Access-Jwt-Assertion` header. The reverse also happens: the header is attached by
|
|
7
|
+
* the Access hop, so a request that reaches the origin through a further proxy — or a
|
|
8
|
+
* browser replaying its own cookie — can carry the cookie and not the header. Checking
|
|
9
|
+
* one only is a 401 that looks like a broken login and is actually a missing branch.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THE HEADER WINS WHEN BOTH ARE PRESENT, and that order is load-bearing. The header is
|
|
12
|
+
* set by the Access hop on this request; the cookie is whatever the client's jar held.
|
|
13
|
+
* Preferring the cookie would let a client pin a stale token over the fresh assertion.
|
|
14
|
+
* ⚠️ Neither is trusted here — both are verified identically by `verifyAccessToken`.
|
|
15
|
+
* This is about which candidate is tried, not about which is believed.
|
|
16
|
+
*/
|
|
17
|
+
/** The header Access sets on the request it forwards to the origin. */
|
|
18
|
+
export declare const ACCESS_HEADER = "Cf-Access-Jwt-Assertion";
|
|
19
|
+
/** The cookie Access sets in the browser. */
|
|
20
|
+
export declare const ACCESS_COOKIE = "CF_Authorization";
|
|
21
|
+
/** Where a candidate token was found. Useful in a log line; never a trust signal. */
|
|
22
|
+
export type TokenSource = 'header' | 'cookie';
|
|
23
|
+
export interface FoundToken {
|
|
24
|
+
readonly token: string;
|
|
25
|
+
readonly source: TokenSource;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read one cookie out of a `Cookie` header value.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ HAND-PARSED, AND SMALL ON PURPOSE. A cookie library would bring parsing of
|
|
31
|
+
* attributes (Path, SameSite, Max-Age) that only ever appear on `Set-Cookie`, which is
|
|
32
|
+
* the response side. Requests carry a bare `name=value; name=value` list, and that is
|
|
33
|
+
* all this reads.
|
|
34
|
+
* ⚠️ A JWT is base64url and contains no `;` or `=`-in-value ambiguity beyond padding, but
|
|
35
|
+
* this still splits on the FIRST `=` only — a value containing `=` (base64 padding) is
|
|
36
|
+
* otherwise truncated mid-token, which fails verification with a signature error and
|
|
37
|
+
* sends you hunting in the wrong place.
|
|
38
|
+
*/
|
|
39
|
+
export declare function readCookie(header: string | null, name: string): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* The Access token on `request`, or `undefined` when it carries none.
|
|
42
|
+
*
|
|
43
|
+
* ⛔ RETURNS THE CANDIDATE UNVERIFIED. Every caller must hand the result to
|
|
44
|
+
* `verifyAccessToken`; nothing about being present in a header makes a token valid.
|
|
45
|
+
*/
|
|
46
|
+
export declare function accessToken(request: Request): FoundToken | undefined;
|
|
47
|
+
//# sourceMappingURL=token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../../src/access-auth/token.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,uEAAuE;AACvE,eAAO,MAAM,aAAa,4BAA4B,CAAC;AAEvD,6CAA6C;AAC7C,eAAO,MAAM,aAAa,qBAAqB,CAAC;AAEhD,qFAAqF;AACrF,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE9C,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAalF;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,UAAU,GAAG,SAAS,CAQpE"}
|
package/dist/access.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ export interface AccessOptions {
|
|
|
3
3
|
readonly teamDomain: string;
|
|
4
4
|
/** The application's AUD tag, from Zero Trust → Access → Applications. */
|
|
5
5
|
readonly audience: string;
|
|
6
|
+
/**
|
|
7
|
+
* Reject a token older than this, by `iat`. Seconds, or a jose duration like `'8h'`.
|
|
8
|
+
* ★ OPT-IN BECAUSE `exp` ALREADY BOUNDS THE TOKEN. Access sets the session lifetime in
|
|
9
|
+
* `exp`, so this is a second, shorter leash for a surface that wants one — not a
|
|
10
|
+
* missing check. ⚠️ Setting it below the Access session duration logs users out
|
|
11
|
+
* mid-session with a 401 they cannot fix by retrying.
|
|
12
|
+
*/
|
|
13
|
+
readonly maxTokenAge?: string | number;
|
|
14
|
+
/** Seconds of clock skew tolerated on `exp`/`nbf`/`iat`. jose's default is 0. */
|
|
15
|
+
readonly clockTolerance?: string | number;
|
|
6
16
|
}
|
|
7
17
|
/** The subset of Access claims worth depending on. */
|
|
8
18
|
export interface AccessIdentity {
|
|
@@ -20,4 +30,18 @@ export interface AccessIdentity {
|
|
|
20
30
|
* want a soft path catch it explicitly.
|
|
21
31
|
*/
|
|
22
32
|
export declare function verifyAccessJwt(request: Request, options: AccessOptions): Promise<AccessIdentity>;
|
|
33
|
+
/**
|
|
34
|
+
* Verify a token string that the caller has already located, returning the identity.
|
|
35
|
+
*
|
|
36
|
+
* ★ EXTRACTED SO THERE IS EXACTLY ONE VERIFICATION, NOT TWO. `verifyAccessJwt` reads the
|
|
37
|
+
* header; the forward_auth handler (`@homeflare/cloudflare/access-auth`) also accepts
|
|
38
|
+
* the `CF_Authorization` cookie, because Cloudflare's own documentation says the cookie
|
|
39
|
+
* "is not guaranteed to be passed" and the reverse is true too — a request that arrives
|
|
40
|
+
* through a proxy hop may carry one and not the other. Both funnel here, so a fix to the
|
|
41
|
+
* checks below cannot land on one path and miss the other.
|
|
42
|
+
*
|
|
43
|
+
* ⛔ THROWS ON EVERY FAILURE, exactly like `verifyAccessJwt`. Same reasoning: a boolean a
|
|
44
|
+
* caller forgets to test is an open door.
|
|
45
|
+
*/
|
|
46
|
+
export declare function verifyAccessToken(token: string, options: AccessOptions): Promise<AccessIdentity>;
|
|
23
47
|
//# sourceMappingURL=access.d.ts.map
|
package/dist/access.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"AA0BA,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"AA0BA,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvC,iFAAiF;IACjF,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC3C;AAED,sDAAsD;AACtD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACpD;AA2BD;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,cAAc,CAAC,CAKzB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,cAAc,CAAC,CAiBzB"}
|
package/dist/index.d.ts
CHANGED
|
@@ -20,7 +20,14 @@ export { VERSION } from './version.ts';
|
|
|
20
20
|
* how to authenticate; RFC 9728 is what does.
|
|
21
21
|
*/
|
|
22
22
|
export { accessIdentity, hasAccess, type AccessContext, type AccessIdentityInfo, } from './access-identity.ts';
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* ⚠️ `verifyAccessToken` IS THE SAME CHECK WITH THE TOKEN ALREADY IN HAND — for a caller
|
|
25
|
+
* that read it from somewhere other than the header (a cookie, a WebSocket subprotocol).
|
|
26
|
+
* It is not a lighter variant: identical issuer, audience and expiry checks.
|
|
27
|
+
* ★ Protecting a non-Worker origin behind a reverse proxy? `@homeflare/cloudflare/access-auth`
|
|
28
|
+
* is the ready-made `forward_auth` handler; you do not need to assemble one from these.
|
|
29
|
+
*/
|
|
30
|
+
export { verifyAccessJwt, verifyAccessToken, type AccessIdentity, type AccessOptions, } from './access.ts';
|
|
24
31
|
export { protectedResourceMetadata, serveMcpMetadata, unauthorizedResponse, wellKnownPath, type McpMetadataOptions, type ProtectedResourceMetadata, } from './mcp-metadata.ts';
|
|
25
32
|
export { log, type LogFields, type Logger } from './log.ts';
|
|
26
33
|
export { BREAKER_COOLDOWN_MS, breakered } from './jwks-breaker.ts';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC;;;;;;;;;;GAUG;AACH,OAAO,EACL,cAAc,EACd,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC;;;;;;;;;;GAUG;AACH,OAAO,EACL,cAAc,EACd,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B;;;;;;GAMG;AACH,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,KAAK,cAAc,EACnB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,yBAAyB,EACzB,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,yBAAyB,GAC/B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,GAAG,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/version.ts
|
|
2
|
-
var VERSION = "0.3.
|
|
2
|
+
var VERSION = "0.3.2";
|
|
3
3
|
// src/access-identity.ts
|
|
4
4
|
function hasAccess(ctx) {
|
|
5
5
|
return ctx.access !== undefined;
|
|
@@ -63,9 +63,14 @@ async function verifyAccessJwt(request, options) {
|
|
|
63
63
|
const token = request.headers.get(ACCESS_HEADER);
|
|
64
64
|
if (token === null)
|
|
65
65
|
throw new Error("access: no Cf-Access-Jwt-Assertion header");
|
|
66
|
+
return await verifyAccessToken(token, options);
|
|
67
|
+
}
|
|
68
|
+
async function verifyAccessToken(token, options) {
|
|
66
69
|
const { payload } = await jwtVerify(token, keysFor(options.teamDomain), {
|
|
67
70
|
issuer: options.teamDomain,
|
|
68
|
-
audience: options.audience
|
|
71
|
+
audience: options.audience,
|
|
72
|
+
...options.maxTokenAge === undefined ? {} : { maxTokenAge: options.maxTokenAge },
|
|
73
|
+
...options.clockTolerance === undefined ? {} : { clockTolerance: options.clockTolerance }
|
|
69
74
|
});
|
|
70
75
|
return {
|
|
71
76
|
email: typeof payload["email"] === "string" ? payload["email"] : undefined,
|
|
@@ -140,8 +145,9 @@ export {
|
|
|
140
145
|
serveMcpMetadata,
|
|
141
146
|
unauthorizedResponse,
|
|
142
147
|
verifyAccessJwt,
|
|
148
|
+
verifyAccessToken,
|
|
143
149
|
wellKnownPath
|
|
144
150
|
};
|
|
145
151
|
|
|
146
|
-
//# debugId=
|
|
152
|
+
//# debugId=CE0760F85A084BBF64756E2164756E21
|
|
147
153
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/version.ts", "../src/access-identity.ts", "../src/access.ts", "../src/jwks-breaker.ts", "../src/mcp-metadata.ts", "../src/log.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.3.
|
|
5
|
+
"// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.3.2';\n",
|
|
6
6
|
"/**\n * Access identity WITHOUT parsing a JWT — the path Cloudflare added in August 2026.\n *\n * ★ WHEN ACCESS PROTECTS A WORKER, the edge attaches the authenticated identity to the\n * execution context: `ctx.access.getIdentity()` returns email, name and groups with no\n * token handling at all. Verifying the assertion yourself is the OLDER path, and it is\n * still correct for origins that have no `ctx.access` — see `verifyAccessJwt`.\n *\n * ⛔ THIS IS NOT A WEAKER CHECK. The edge has already refused the request if it did not\n * pass the Access policy; `ctx.access` exists only on requests that passed. Parsing the\n * JWT again re-verifies something the platform has already enforced.\n *\n * ⚠️ `ctx.access` IS NOT PROPAGATED THROUGH SERVICE BINDINGS OR RPC. A downstream Worker\n * reached by binding sees `undefined`, even though the caller was authenticated — so a\n * service-bound Worker that needs identity must be told, or must sit behind its own\n * Access application. Measured from Cloudflare's own documentation, 2026-08-14.\n *\n * ★ GROUPS COME FROM get-identity, NOT FROM THE TOKEN. Cloudflare trims the JWT's `custom`\n * claim at roughly 1 KB, silently, so group membership read from a token can be\n * incomplete — which is an authorization bug that only appears for users in many groups.\n */\n\n/** What `ctx.access.getIdentity()` resolves. Narrowed to the fields worth depending on. */\nexport interface AccessIdentityInfo {\n readonly email: string | undefined;\n readonly name: string | undefined;\n readonly groups: readonly string[];\n /** Everything else the platform returned, for callers needing a field not named here. */\n readonly raw: Readonly<Record<string, unknown>>;\n}\n\n/**\n * The slice of the execution context this module uses.\n * ⚠️ Declared structurally rather than imported from `@cloudflare/workers-types`: this\n * package should not force a types dependency on a consumer for one optional field.\n */\nexport interface AccessContext {\n readonly access?: {\n readonly aud?: string;\n readonly getIdentity: () => Promise<Record<string, unknown> | null | undefined>;\n };\n}\n\n/** True when the request reached this Worker through Cloudflare Access. */\nexport function hasAccess(ctx: AccessContext): boolean {\n return ctx.access !== undefined;\n}\n\n/**\n * The authenticated identity, or `undefined` when the request did not come through Access.\n *\n * ```ts\n * const who = await accessIdentity(ctx);\n * if (who === undefined) return new Response('Access did not run', { status: 401 });\n * ```\n *\n * ⛔ RETURNS undefined RATHER THAN THROWING, deliberately — unlike `verifyAccessJwt`. A\n * Worker may legitimately serve both protected and open routes, so \"no Access here\" is\n * a branch rather than a failure. The caller decides what an unauthenticated request\n * means, and that decision is visible at the call site.\n */\nexport async function accessIdentity(ctx: AccessContext): Promise<AccessIdentityInfo | undefined> {\n const access = ctx.access;\n if (access === undefined) return undefined;\n\n const raw = (await access.getIdentity()) ?? {};\n const groups = raw['groups'];\n\n return {\n email: typeof raw['email'] === 'string' ? raw['email'] : undefined,\n name: typeof raw['name'] === 'string' ? raw['name'] : undefined,\n // ⚠️ Always an array, never undefined: a caller writing `groups.includes(…)` on a\n // missing field would throw at exactly the moment authorization is being decided.\n groups: Array.isArray(groups) ? groups.filter((g): g is string => typeof g === 'string') : [],\n raw,\n };\n}\n",
|
|
7
|
-
"/**\n * Cloudflare Access JWT verification.\n *\n * ★ WHY jose AND NOT A HAND-ROLLED VERIFY. Signature verification is the one place where\n * a subtle bug is silent and total: a check that never fails looks identical to a\n * check that always passes. jose handles JWKS fetching, `kid` matching and key\n * rotation, and it is widely deployed. This file is the Access-specific\n * wiring around it, nothing more.\n *\n * ⚠️ THE TRAP THIS CLOSES. Cloudflare's own example does `jwtVerify(...)` inline in the\n * handler, which builds a NEW remote JWKS on every request — an outbound fetch per\n * request, and a thundering herd on the certs endpoint under load. The cache below is\n * keyed by team domain and lives for the isolate's lifetime.\n *\n * ⛔ NEVER SKIP `audience`. Any Access app in your account produces a JWT this team\n * domain will happily verify; the AUD tag is the only thing that says the token was\n * minted for THIS app. Verifying issuer alone accepts tokens from a neighbouring app.\n *\n * Docs: developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/\n */\nimport { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';\nimport { breakered } from './jwks-breaker.ts';\n\n/** The header Access puts the token in. Lowercase — Headers.get is case-insensitive. */\nconst ACCESS_HEADER = 'cf-access-jwt-assertion';\n\nexport interface AccessOptions {\n /** `https://<team>.cloudflareaccess.com` — the issuer, and the JWKS host. */\n readonly teamDomain: string;\n /** The application's AUD tag, from Zero Trust → Access → Applications. */\n readonly audience: string;\n}\n\n/** The subset of Access claims worth depending on. */\nexport interface AccessIdentity {\n readonly email: string | undefined;\n readonly sub: string | undefined;\n /** Everything else, for callers that need a claim this interface does not name. */\n readonly claims: Readonly<Record<string, unknown>>;\n}\n\n/**\n * One JWKS per team domain, reused for the isolate's life.\n * ⚠️ Module scope is per-isolate on workerd, not per-request and not global. That is\n * exactly the right lifetime here: long enough to amortise the fetch, short enough\n * that a rotated key is picked up when the isolate recycles. jose also refreshes on\n * an unknown `kid`.\n */\nconst jwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\nfunction keysFor(teamDomain: string): ReturnType<typeof createRemoteJWKSet> {\n const existing = jwks.get(teamDomain);\n if (existing !== undefined) return existing;\n\n // ⛔ THE BREAKER IS NOT OPTIONAL. jose refetches on EVERY call while an endpoint is\n // down — measured on 6.2.12: 5 verifications against a 404ing certs endpoint made 5\n // outbound fetches, and no combination of cacheMaxAge or cooldownDuration changes it.\n // Without this, a Cloudflare Access outage turns every inbound request into an\n // outbound one. See jwks-breaker.ts.\n const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`), {\n [customFetch]: breakered(),\n });\n jwks.set(teamDomain, created);\n return created;\n}\n\n/**\n * Verify the Access JWT on `request`, returning the identity it asserts.\n *\n * ⛔ THROWS ON EVERY FAILURE — missing header, bad signature, wrong audience, expired.\n * ★ Deliberately not a boolean or a null: an `if (!ok)` that a caller forgets to write\n * is an open door, whereas an unhandled throw is a 500 and a log line. Callers that\n * want a soft path catch it explicitly.\n */\nexport async function verifyAccessJwt(\n request: Request,\n options: AccessOptions,\n): Promise<AccessIdentity> {\n const token = request.headers.get(ACCESS_HEADER);\n if (token === null) throw new Error('access: no Cf-Access-Jwt-Assertion header');\n\n const { payload } = await jwtVerify(token, keysFor(options.teamDomain), {\n issuer: options.teamDomain,\n audience: options.audience,\n });\n\n return {\n email: typeof payload['email'] === 'string' ? payload['email'] : undefined,\n sub: payload.sub,\n claims: payload,\n };\n}\n",
|
|
7
|
+
"/**\n * Cloudflare Access JWT verification.\n *\n * ★ WHY jose AND NOT A HAND-ROLLED VERIFY. Signature verification is the one place where\n * a subtle bug is silent and total: a check that never fails looks identical to a\n * check that always passes. jose handles JWKS fetching, `kid` matching and key\n * rotation, and it is widely deployed. This file is the Access-specific\n * wiring around it, nothing more.\n *\n * ⚠️ THE TRAP THIS CLOSES. Cloudflare's own example does `jwtVerify(...)` inline in the\n * handler, which builds a NEW remote JWKS on every request — an outbound fetch per\n * request, and a thundering herd on the certs endpoint under load. The cache below is\n * keyed by team domain and lives for the isolate's lifetime.\n *\n * ⛔ NEVER SKIP `audience`. Any Access app in your account produces a JWT this team\n * domain will happily verify; the AUD tag is the only thing that says the token was\n * minted for THIS app. Verifying issuer alone accepts tokens from a neighbouring app.\n *\n * Docs: developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/\n */\nimport { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';\nimport { breakered } from './jwks-breaker.ts';\n\n/** The header Access puts the token in. Lowercase — Headers.get is case-insensitive. */\nconst ACCESS_HEADER = 'cf-access-jwt-assertion';\n\nexport interface AccessOptions {\n /** `https://<team>.cloudflareaccess.com` — the issuer, and the JWKS host. */\n readonly teamDomain: string;\n /** The application's AUD tag, from Zero Trust → Access → Applications. */\n readonly audience: string;\n /**\n * Reject a token older than this, by `iat`. Seconds, or a jose duration like `'8h'`.\n * ★ OPT-IN BECAUSE `exp` ALREADY BOUNDS THE TOKEN. Access sets the session lifetime in\n * `exp`, so this is a second, shorter leash for a surface that wants one — not a\n * missing check. ⚠️ Setting it below the Access session duration logs users out\n * mid-session with a 401 they cannot fix by retrying.\n */\n readonly maxTokenAge?: string | number;\n /** Seconds of clock skew tolerated on `exp`/`nbf`/`iat`. jose's default is 0. */\n readonly clockTolerance?: string | number;\n}\n\n/** The subset of Access claims worth depending on. */\nexport interface AccessIdentity {\n readonly email: string | undefined;\n readonly sub: string | undefined;\n /** Everything else, for callers that need a claim this interface does not name. */\n readonly claims: Readonly<Record<string, unknown>>;\n}\n\n/**\n * One JWKS per team domain, reused for the isolate's life.\n * ⚠️ Module scope is per-isolate on workerd, not per-request and not global. That is\n * exactly the right lifetime here: long enough to amortise the fetch, short enough\n * that a rotated key is picked up when the isolate recycles. jose also refreshes on\n * an unknown `kid`.\n */\nconst jwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\nfunction keysFor(teamDomain: string): ReturnType<typeof createRemoteJWKSet> {\n const existing = jwks.get(teamDomain);\n if (existing !== undefined) return existing;\n\n // ⛔ THE BREAKER IS NOT OPTIONAL. jose refetches on EVERY call while an endpoint is\n // down — measured on 6.2.12: 5 verifications against a 404ing certs endpoint made 5\n // outbound fetches, and no combination of cacheMaxAge or cooldownDuration changes it.\n // Without this, a Cloudflare Access outage turns every inbound request into an\n // outbound one. See jwks-breaker.ts.\n const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`), {\n [customFetch]: breakered(),\n });\n jwks.set(teamDomain, created);\n return created;\n}\n\n/**\n * Verify the Access JWT on `request`, returning the identity it asserts.\n *\n * ⛔ THROWS ON EVERY FAILURE — missing header, bad signature, wrong audience, expired.\n * ★ Deliberately not a boolean or a null: an `if (!ok)` that a caller forgets to write\n * is an open door, whereas an unhandled throw is a 500 and a log line. Callers that\n * want a soft path catch it explicitly.\n */\nexport async function verifyAccessJwt(\n request: Request,\n options: AccessOptions,\n): Promise<AccessIdentity> {\n const token = request.headers.get(ACCESS_HEADER);\n if (token === null) throw new Error('access: no Cf-Access-Jwt-Assertion header');\n\n return await verifyAccessToken(token, options);\n}\n\n/**\n * Verify a token string that the caller has already located, returning the identity.\n *\n * ★ EXTRACTED SO THERE IS EXACTLY ONE VERIFICATION, NOT TWO. `verifyAccessJwt` reads the\n * header; the forward_auth handler (`@homeflare/cloudflare/access-auth`) also accepts\n * the `CF_Authorization` cookie, because Cloudflare's own documentation says the cookie\n * \"is not guaranteed to be passed\" and the reverse is true too — a request that arrives\n * through a proxy hop may carry one and not the other. Both funnel here, so a fix to the\n * checks below cannot land on one path and miss the other.\n *\n * ⛔ THROWS ON EVERY FAILURE, exactly like `verifyAccessJwt`. Same reasoning: a boolean a\n * caller forgets to test is an open door.\n */\nexport async function verifyAccessToken(\n token: string,\n options: AccessOptions,\n): Promise<AccessIdentity> {\n const { payload } = await jwtVerify(token, keysFor(options.teamDomain), {\n issuer: options.teamDomain,\n audience: options.audience,\n // ⛔ SPREAD, NOT `maxTokenAge: options.maxTokenAge`. `exactOptionalPropertyTypes` is on,\n // and passing an explicit `undefined` is not the same as omitting the key — jose\n // reads `'maxTokenAge' in options`, so the undefined form would enable the check\n // with an undefined bound.\n ...(options.maxTokenAge === undefined ? {} : { maxTokenAge: options.maxTokenAge }),\n ...(options.clockTolerance === undefined ? {} : { clockTolerance: options.clockTolerance }),\n });\n\n return {\n email: typeof payload['email'] === 'string' ? payload['email'] : undefined,\n sub: payload.sub,\n claims: payload,\n };\n}\n",
|
|
8
8
|
"/**\n * A circuit breaker for JWKS fetches, installed at jose's own `customFetch` seam.\n *\n * ⛔ THE AMPLIFICATION THIS CLOSES, MEASURED ON jose 6.2.12 — the version this package\n * pins, not an old one:\n *\n * 5 verifications of a well-formed token, certs endpoint 404ing → 5 outbound fetches\n *\n * jose assigns `jwksTimestamp` only inside the `.then()` of a SUCCESSFUL fetch\n * (`dist/webapi/jwks/remote.js`, line 58). While an endpoint is down, `local` stays\n * undefined, so `if (!local || !isFreshFor(jwksTimestamp, cacheMaxAge)) await reload()`\n * runs on EVERY call. A down Access endpoint therefore turns every inbound request into\n * an outbound fetch, indefinitely.\n *\n * ★ NOT A REIMPLEMENTATION. `customFetch` is jose's exported, documented extension point;\n * jose keeps doing the caching, cooldown, `kid` matching and verification. This adds the\n * one thing it does not do — remember that a fetch FAILED.\n * ⚠️ Measured first: neither `cacheMaxAge: Infinity` nor `cooldownDuration` nor both\n * together change the count. It is 5 fetches in every configuration, so this is not a\n * matter of configuring jose correctly.\n *\n * ⛔ THE BREAKER STORES PLAIN VALUES ONLY — a timestamp and an Error. NEVER a Response, a\n * body stream, or a promise of one.\n * 🔴 On workerd, a Response and its body belong to the IoContext of the request that\n * created them, and another request touching either throws:\n * Cannot perform I/O on behalf of a different request.\n * Caching a Response across requests would therefore turn a VALID token into a flat\n * 401 for every caller but the first — an outage strictly worse than the amplification\n * it was meant to fix. Timestamps and Errors cross requests legally; I/O objects do not.\n */\n\nimport type { FetchImplementation } from 'jose';\n\n/** How long a URL stays latched open after a failed probe. */\nexport const BREAKER_COOLDOWN_MS = 30_000;\n\ntype Failure = { readonly at: number; readonly error: Error };\n\n/**\n * ⚠️ Module scope is PER ISOLATE on workerd, which is the right lifetime: long enough to\n * stop the storm, short enough that a recycled isolate retries a recovered endpoint.\n */\nconst failures = new Map<string, Failure>();\n\n/** Visible for testing. ⛔ Not exported from the package — resetting it in production\n * would discard exactly the memory this exists to keep. */\nexport function resetBreaker(): void {\n failures.clear();\n}\n\n/**\n * Wrap a fetch so a failing URL is not retried for `cooldownMs`.\n *\n * ★ FAILURE IS COUNTED AT THE SEAM, not inferred from an error class further up. jose\n * throws `JWKSNoMatchingKey` as a NORMAL answer from a perfectly healthy endpoint — a\n * token signed with a rotated key does that — so treating it as a fault would latch a\n * working endpoint closed and reject every valid token for 30 seconds.\n */\nexport function breakered(\n inner: FetchImplementation = (url, options) => fetch(url, options),\n cooldownMs: number = BREAKER_COOLDOWN_MS,\n): FetchImplementation {\n // ⚠️ jose's OWN FetchImplementation, not `typeof fetch`. It is narrower — a string URL\n // and a fixed options shape — and using the global type instead fails to typecheck on\n // `preconnect`, a property of the platform fetch that no seam implementation has.\n return async (url, options) => {\n const open = failures.get(url);\n\n if (open !== undefined && Date.now() - open.at < cooldownMs) {\n // ⚠️ Rethrow the ORIGINAL error, not a generic one: the first failure says whether\n // this was a 404, a TLS problem or a timeout, and that is what an operator needs.\n // A \"circuit open\" message alone hides the actual fault.\n throw open.error;\n }\n\n try {\n const response = await inner(url, options);\n\n // ⛔ A non-200 is a failure here. jose requires 200 with a JSON body; a 404 or a 502\n // is exactly the down-endpoint case, and it does NOT throw on its own.\n if (!response.ok) {\n const error = new Error(`JWKS endpoint answered ${String(response.status)}: ${url}`);\n failures.set(url, { at: Date.now(), error });\n throw error;\n }\n\n failures.delete(url);\n return response;\n } catch (cause) {\n const error = cause instanceof Error ? cause : new Error(String(cause));\n // `set` is idempotent here — a second failure simply restarts the cooldown.\n failures.set(url, { at: Date.now(), error });\n throw error;\n }\n };\n}\n",
|
|
9
9
|
"/**\n * RFC 9728 discovery for an MCP server — the third auth path, and the one a client\n * actually needs to find its way in.\n *\n * ★ THE MCP SPEC REQUIRES THIS. A server MUST publish OAuth 2.0 Protected Resource\n * Metadata, and a client MUST use it to discover the authorization server. Without it a\n * client cannot begin the flow, and has to be told endpoints out of band — which is\n * exactly the gap that leaves real servers (Atlassian's, measured 2026) undiscoverable\n * by spec-compliant clients.\n *\n * ⛔ TWO HALVES, AND ONE ALONE DOES NOT WORK:\n * 1. `GET /.well-known/oauth-protected-resource` returns the metadata document.\n * 2. A 401 carries `WWW-Authenticate` naming that document's URL.\n * A client may start from either. Publishing the document but answering a bare 401 leaves\n * clients that follow the header — the common path — with nowhere to go.\n *\n * ⚠️ THE WELL-KNOWN URL IS DERIVED FROM THE RESOURCE URL, NOT CHOSEN. That derivation is a\n * security property, not a convention: it stops an attacker publishing metadata that\n * claims to describe a resource it has no authority over.\n *\n * ★ NO I/O AT MODULE SCOPE. Everything here is pure, because a Worker cannot make network\n * calls during module initialization — a metadata helper that fetched at import would\n * fail on the edge and nowhere else.\n */\n\n/** The subset of RFC 9728 an MCP server needs. Extra fields pass through untouched. */\nexport interface ProtectedResourceMetadata {\n /** The resource's own canonical URL. ⛔ Must match what clients call, exactly. */\n readonly resource: string;\n /** Authorization servers that issue tokens for it. */\n readonly authorization_servers: readonly string[];\n readonly scopes_supported?: readonly string[];\n readonly bearer_methods_supported?: readonly string[];\n readonly resource_documentation?: string;\n}\n\nexport interface McpMetadataOptions {\n /** The MCP endpoint clients call, e.g. `https://mcp.example.com/mcp`. */\n readonly resource: string;\n /**\n * The issuer. For Cloudflare's managed OAuth this is your team domain,\n * `https://<team>.cloudflareaccess.com`.\n */\n readonly authorizationServer: string;\n readonly scopes?: readonly string[];\n readonly documentation?: string;\n}\n\n/**\n * The well-known path for a resource URL, derived per RFC 9728.\n *\n * ⚠️ A RESOURCE WITH A PATH KEEPS IT, AFTER the well-known segment:\n * https://example.com/mcp → /.well-known/oauth-protected-resource/mcp\n * Serving it at the root instead is the mistake that makes a multi-resource server\n * advertise one resource's metadata for all of them.\n */\nexport function wellKnownPath(resource: string): string {\n const path = new URL(resource).pathname.replace(/\\/+$/, '');\n return path === ''\n ? '/.well-known/oauth-protected-resource'\n : `/.well-known/oauth-protected-resource${path}`;\n}\n\n/** Build the metadata document. Pure — no I/O, safe at module scope. */\nexport function protectedResourceMetadata(options: McpMetadataOptions): ProtectedResourceMetadata {\n return {\n resource: options.resource,\n authorization_servers: [options.authorizationServer],\n // ⚠️ `header` only. A bearer token in a query string lands in access logs and in\n // Referer, and RFC 6750 discourages it for exactly that reason.\n bearer_methods_supported: ['header'],\n ...(options.scopes === undefined ? {} : { scopes_supported: options.scopes }),\n ...(options.documentation === undefined\n ? {}\n : { resource_documentation: options.documentation }),\n };\n}\n\n/**\n * The 401 an unauthenticated MCP request must receive.\n *\n * ⛔ THE `resource_metadata` PARAMETER IS THE WHOLE POINT. A bare `WWW-Authenticate:\n * Bearer` tells a client it needs a token but not where to get one, so discovery dead-ends\n * and the client falls back to hardcoded endpoints — or fails.\n */\nexport function unauthorizedResponse(options: McpMetadataOptions): Response {\n const metadataUrl = new URL(wellKnownPath(options.resource), options.resource).href;\n\n return new Response(JSON.stringify({ error: 'unauthorized' }), {\n status: 401,\n headers: {\n 'content-type': 'application/json',\n 'www-authenticate': `Bearer resource_metadata=\"${metadataUrl}\"`,\n },\n });\n}\n\n/**\n * Serve the metadata document when the request is for it, otherwise `undefined`.\n *\n * ```ts\n * const discovery = serveMcpMetadata(request, { resource, authorizationServer });\n * if (discovery !== undefined) return discovery;\n * ```\n *\n * ⚠️ CORS IS OPEN HERE ON PURPOSE. Discovery metadata is public by definition — a client\n * must read it BEFORE it has a token, often from a browser origin the server has never\n * seen. Locking it down breaks the flow it exists to start.\n */\nexport function serveMcpMetadata(\n request: Request,\n options: McpMetadataOptions,\n): Response | undefined {\n const url = new URL(request.url);\n if (url.pathname !== wellKnownPath(options.resource)) return undefined;\n\n return new Response(JSON.stringify(protectedResourceMetadata(options), null, 2), {\n headers: {\n 'content-type': 'application/json',\n 'access-control-allow-origin': '*',\n 'cache-control': 'public, max-age=3600',\n },\n });\n}\n",
|
|
10
10
|
"/**\n * Structured logging for Workers.\n *\n * ★ WHY NO LOGGING LIBRARY. Cloudflare Workers Logs parses `console.log` output as JSON\n * natively and indexes every field, with unlimited cardinality — so a JSON object on\n * console IS the structured logging integration. pino is Node-shaped (streams, worker\n * threads) and needs a wrapper on workerd; `pino-pretty` does not work with wrangler\n * at all (workers-sdk#6841). Adding a dependency here would buy formatting we do not\n * want and cost bundle size on a hot path.\n * Docs: developers.cloudflare.com/workers/observability/logs/workers-logs/\n *\n * ⛔ NEVER LOG A CREDENTIAL. There is no redaction layer here and deliberately so — a\n * redactor implies it is safe to pass secrets in, and then quietly misses one.\n */\n\n/** Anything JSON-serialisable. Workers Logs indexes each key as its own field. */\nexport type LogFields = Readonly<Record<string, unknown>>;\n\nexport interface Logger {\n readonly debug: (message: string, fields?: LogFields) => void;\n readonly info: (message: string, fields?: LogFields) => void;\n readonly warn: (message: string, fields?: LogFields) => void;\n readonly error: (message: string, fields?: LogFields) => void;\n /** Returns a logger that merges `bound` into every subsequent line. */\n readonly with: (bound: LogFields) => Logger;\n}\n\ntype Level = 'debug' | 'info' | 'warn' | 'error';\n\nfunction emit(level: Level, message: string, fields: LogFields): void {\n // ⚠️ ONE OBJECT, NOT (message, fields). console.log with several arguments is\n // stringified and concatenated by the runtime, which destroys the structure Workers\n // Logs would otherwise index — the symptom is a `message` field containing your\n // whole payload as text, unqueryable.\n const line = { level, message, ...fields };\n\n if (level === 'error') console.error(line);\n else if (level === 'warn') console.warn(line);\n else console.log(line);\n}\n\nfunction make(bound: LogFields): Logger {\n return {\n debug: (message, fields) => emit('debug', message, { ...bound, ...fields }),\n info: (message, fields) => emit('info', message, { ...bound, ...fields }),\n warn: (message, fields) => emit('warn', message, { ...bound, ...fields }),\n error: (message, fields) => emit('error', message, { ...bound, ...fields }),\n with: (extra) => make({ ...bound, ...extra }),\n };\n}\n\n/**\n * The root logger. Bind request-scoped context rather than passing it every call:\n *\n * const requestLog = log.with({ requestId, path: url.pathname });\n * requestLog.info('handled', { status, ms });\n */\nexport const log: Logger = make({});\n"
|
|
11
11
|
],
|
|
12
|
-
"mappings": ";AACO,IAAM,UAAkB;;AC2CxB,SAAS,SAAS,CAAC,KAA6B;AAAA,EACrD,OAAO,IAAI,WAAW;AAAA;AAgBxB,eAAsB,cAAc,CAAC,KAA6D;AAAA,EAChG,MAAM,SAAS,IAAI;AAAA,EACnB,IAAI,WAAW;AAAA,IAAW;AAAA,EAE1B,MAAM,MAAO,MAAM,OAAO,YAAY,KAAM,CAAC;AAAA,EAC7C,MAAM,SAAS,IAAI;AAAA,EAEnB,OAAO;AAAA,IACL,OAAO,OAAO,IAAI,aAAa,WAAW,IAAI,WAAW;AAAA,IACzD,MAAM,OAAO,IAAI,YAAY,WAAW,IAAI,UAAU;AAAA,IAGtD,QAAQ,MAAM,QAAQ,MAAM,IAAI,OAAO,OAAO,CAAC,MAAmB,OAAO,MAAM,QAAQ,IAAI,CAAC;AAAA,IAC5F;AAAA,EACF;AAAA;;ACvDF;;;ACcO,IAAM,sBAAsB;AAQnC,IAAM,WAAW,IAAI;AAgBd,SAAS,SAAS,CACvB,QAA6B,CAAC,KAAK,YAAY,MAAM,KAAK,OAAO,GACjE,aAAqB,qBACA;AAAA,EAIrB,OAAO,OAAO,KAAK,YAAY;AAAA,IAC7B,MAAM,OAAO,SAAS,IAAI,GAAG;AAAA,IAE7B,IAAI,SAAS,aAAa,KAAK,IAAI,IAAI,KAAK,KAAK,YAAY;AAAA,MAI3D,MAAM,KAAK;AAAA,IACb;AAAA,IAEA,IAAI;AAAA,MACF,MAAM,WAAW,MAAM,MAAM,KAAK,OAAO;AAAA,MAIzC,IAAI,CAAC,SAAS,IAAI;AAAA,QAChB,MAAM,QAAQ,IAAI,MAAM,0BAA0B,OAAO,SAAS,MAAM,MAAM,KAAK;AAAA,QACnF,SAAS,IAAI,KAAK,EAAE,IAAI,KAAK,IAAI,GAAG,MAAM,CAAC;AAAA,QAC3C,MAAM;AAAA,MACR;AAAA,MAEA,SAAS,OAAO,GAAG;AAAA,MACnB,OAAO;AAAA,MACP,OAAO,OAAO;AAAA,MACd,MAAM,QAAQ,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;AAAA,MAEtE,SAAS,IAAI,KAAK,EAAE,IAAI,KAAK,IAAI,GAAG,MAAM,CAAC;AAAA,MAC3C,MAAM;AAAA;AAAA;AAAA;;;ADpEZ,IAAM,gBAAgB;
|
|
13
|
-
"debugId": "
|
|
12
|
+
"mappings": "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",
|
|
13
|
+
"debugId": "CE0760F85A084BBF64756E2164756E21",
|
|
14
14
|
"names": []
|
|
15
15
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Access for a surface that is not a Worker — `@homeflare/cloudflare/access-auth`
|
|
2
|
+
|
|
3
|
+
Cloudflare Access authenticates the caller at the edge and forwards a signed JWT. A Worker
|
|
4
|
+
reads the identity from `ctx.access` and is done. **Anything else — a daemon on a host, a
|
|
5
|
+
web UI behind a tunnel — has to verify that JWT itself**, and that is what this is: a
|
|
6
|
+
`forward_auth` verifier a reverse proxy calls before it lets a request reach the upstream.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
browser ─► Cloudflare Access ─► tunnel ─► Caddy ─┬─► verifier (this package)
|
|
10
|
+
└─► upstream (only on 2xx)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## The trap: verifying the wrong AUD admits everyone
|
|
14
|
+
|
|
15
|
+
⛔ **Every Access application in one account is signed by the same team keys.** A token
|
|
16
|
+
minted for a different app of yours has a valid signature, a valid `iss`, and an unexpired
|
|
17
|
+
`exp`. The `aud` claim is the _only_ thing that says the token was minted for **this**
|
|
18
|
+
application.
|
|
19
|
+
|
|
20
|
+
A verifier that checks the issuer and skips the audience therefore admits **every Access
|
|
21
|
+
user of every app you run**. It does not look broken: you log in, it works, your colleagues
|
|
22
|
+
log in, it works. It fails the day someone who was only ever given the staging dashboard
|
|
23
|
+
opens the thing you thought was locked.
|
|
24
|
+
|
|
25
|
+
So `audience` is required, it is fixed when the handler is constructed, and it is never read
|
|
26
|
+
from the request. One handler per Access application — two apps mean two handlers.
|
|
27
|
+
|
|
28
|
+
## Running it
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { accessForwardAuth } from '@homeflare/cloudflare/access-auth';
|
|
32
|
+
|
|
33
|
+
const verify = accessForwardAuth({
|
|
34
|
+
teamDomain: 'https://example.cloudflareaccess.com', // no trailing slash
|
|
35
|
+
audience: process.env['ACCESS_AUD'] ?? '', // the app's AUD tag
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
Bun.serve({ hostname: '127.0.0.1', port: 9101, fetch: verify });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It is a plain `(Request) => Promise<Response>`, so Node's fetch adapters and workerd take it
|
|
42
|
+
unchanged. Answers:
|
|
43
|
+
|
|
44
|
+
| outcome | status | upstream sees |
|
|
45
|
+
| --------------------------------------------------- | ------ | -------------------------------- |
|
|
46
|
+
| verified | 204 | `X-Access-Email`, `X-Access-Sub` |
|
|
47
|
+
| no token, bad signature, wrong `aud`/`iss`, expired | 401 | nothing — request stops |
|
|
48
|
+
| verified but refused by your `authorize` | 403 | nothing — request stops |
|
|
49
|
+
| the team's JWKS endpoint is down | 401 | nothing — **fails closed** |
|
|
50
|
+
|
|
51
|
+
The refusal carries no body and no reason: an unauthenticated caller learns nothing. The
|
|
52
|
+
reason goes to the log as a fixed enum (`no-token`, `invalid-token`, `jwks-unavailable`,
|
|
53
|
+
`not-authorized`, `unexpected`) — `jwks-unavailable` is the one worth alerting on, because
|
|
54
|
+
it is the only one that means _your_ system is broken rather than the caller's token.
|
|
55
|
+
|
|
56
|
+
⛔ **The token is never logged**, at any level, whole or truncated. A logged token is a
|
|
57
|
+
replayable session for the whole of its `exp`.
|
|
58
|
+
|
|
59
|
+
## Wiring it into Caddy
|
|
60
|
+
|
|
61
|
+
`@homeflare/alchemy/caddy` renders the block, so the fields that matter are not retyped:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { accessForwardAuth as forwardAuth, caddyWithFile } from '@homeflare/alchemy/caddy';
|
|
65
|
+
|
|
66
|
+
const caddyfile = `vnc.example.com {
|
|
67
|
+
${forwardAuth({ verifier: '127.0.0.1:9101', body: ['reverse_proxy 127.0.0.1:6080'] })}
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
which produces:
|
|
73
|
+
|
|
74
|
+
```caddyfile
|
|
75
|
+
vnc.example.com {
|
|
76
|
+
reverse_proxy 127.0.0.1:6080
|
|
77
|
+
forward_auth 127.0.0.1:9101 {
|
|
78
|
+
uri /access/verify
|
|
79
|
+
copy_headers {
|
|
80
|
+
X-Access-Email
|
|
81
|
+
X-Access-Sub
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Why `copy_headers` is the security-critical line
|
|
88
|
+
|
|
89
|
+
Caddy's `forward_auth` copies the listed headers **from the verifier's response onto the
|
|
90
|
+
client's request**, then passes it upstream. It expands to roughly:
|
|
91
|
+
|
|
92
|
+
```caddyfile
|
|
93
|
+
@good status 2xx
|
|
94
|
+
handle_response @good {
|
|
95
|
+
request_header X-Access-Email {rp.header.X-Access-Email}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
⚠️ **A client can send `X-Access-Email: admin@example.com` itself.** It is overwritten only
|
|
100
|
+
because the verifier's 204 carries that header. Caddy's documentation does not promise what
|
|
101
|
+
happens to a listed field the auth response omits — so this verifier **always emits every
|
|
102
|
+
identity header on a 2xx, empty string included**, rather than depending on that. A token
|
|
103
|
+
with no `email` claim yields `X-Access-Email: ` and the client's value is gone either way.
|
|
104
|
+
|
|
105
|
+
★ The corollary: **the list in `copy_headers` and the headers the verifier sets must agree.**
|
|
106
|
+
A header the verifier sets but Caddy does not copy never arrives. A header Caddy copies but
|
|
107
|
+
the verifier never sets is the spoofing case above. `accessForwardAuth()` defaults both ends
|
|
108
|
+
to the same two names for exactly that reason; if you rename them, rename them on both sides.
|
|
109
|
+
|
|
110
|
+
### Bind everything to loopback
|
|
111
|
+
|
|
112
|
+
⛔ **Verifying the JWT is worth nothing if the upstream is reachable directly.** The check
|
|
113
|
+
lives in Caddy's request path; anything that can reach the upstream's port skips it entirely.
|
|
114
|
+
Both the verifier and the upstream bind to `127.0.0.1`, and Caddy is the only way in.
|
|
115
|
+
`accessForwardAuth()` refuses a verifier address that is not loopback or a unix socket — not
|
|
116
|
+
because a routable verifier cannot work, but because one is almost always an accident.
|
|
117
|
+
|
|
118
|
+
## Key rotation, caching and outages
|
|
119
|
+
|
|
120
|
+
The JWKS is fetched once per team domain and reused; jose refetches when it meets an unknown
|
|
121
|
+
`kid`, which is how a rotated-in key is picked up without a restart. A certs endpoint that is
|
|
122
|
+
_down_ is latched open for 30s by the breaker in `jwks-breaker.ts` — without it, jose refetches
|
|
123
|
+
on every verification while the endpoint is failing, turning every inbound request into an
|
|
124
|
+
outbound one.
|
|
125
|
+
|
|
126
|
+
⚠️ **An unknown `kid` is a bad token, not an outage.** A key the endpoint does not publish
|
|
127
|
+
means a forgery or a long-rotated token, and it is classified as `invalid-token`. Filing it
|
|
128
|
+
under `jwks-unavailable` would hide a forged token inside the alert that says Cloudflare is
|
|
129
|
+
having a bad day.
|
|
130
|
+
|
|
131
|
+
## What this does not do
|
|
132
|
+
|
|
133
|
+
- **It does not replace the Access policy.** Access decides who may authenticate; this
|
|
134
|
+
decides that the caller actually did. Use `authorize` for an extra rule, not as the gate.
|
|
135
|
+
- **It does not help a Worker.** Behind Access, a Worker has `ctx.access.getIdentity()` and
|
|
136
|
+
should use it — see `accessIdentity` in the package entry.
|
|
137
|
+
- **It does not terminate anything.** If the protected surface speaks a protocol Caddy is
|
|
138
|
+
proxying as raw TCP, there is no HTTP request to carry a JWT and no place for this to run;
|
|
139
|
+
the surface needs an HTTP front (a websockify, a web UI) before Access can guard it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/cloudflare",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Cloudflare Workers helpers: Access identity, MCP OAuth metadata, and structured logging.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/cloudflare#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
+
"docs",
|
|
16
17
|
"src",
|
|
17
18
|
"README.md",
|
|
18
19
|
"LICENSE"
|
|
@@ -24,6 +25,10 @@
|
|
|
24
25
|
"types": "./dist/index.d.ts",
|
|
25
26
|
"default": "./dist/index.js"
|
|
26
27
|
},
|
|
28
|
+
"./access-auth": {
|
|
29
|
+
"types": "./dist/access-auth/index.d.ts",
|
|
30
|
+
"default": "./dist/access-auth/index.js"
|
|
31
|
+
},
|
|
27
32
|
"./package.json": "./package.json"
|
|
28
33
|
},
|
|
29
34
|
"publishConfig": {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Cloudflare Access verifier shaped for Caddy's `forward_auth`.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHAT THIS ADDS, AND WHAT IT DELIBERATELY DOES NOT. The verification is
|
|
5
|
+
* `verifyAccessToken` in `../access.ts` — jose, a cached remote JWKS, a circuit breaker.
|
|
6
|
+
* This file is the HTTP shape around it: find the token, answer 2xx or 4xx, and hand the
|
|
7
|
+
* identity to the upstream as headers. There is no second verifier here, on purpose; two
|
|
8
|
+
* implementations of a signature check is how one of them silently stops checking.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ FAIL CLOSED, ALWAYS. Every path that does not end in a verified identity returns a
|
|
11
|
+
* non-2xx, including a JWKS endpoint that is down. Caddy grants access on 2xx and only
|
|
12
|
+
* on 2xx, so an exception escaping this handler would become a 502 from Caddy — still
|
|
13
|
+
* closed, but without a log line. Hence the catch-all.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ NEVER LOG THE TOKEN. Not at debug, not in an error message, not truncated. A token in
|
|
16
|
+
* a log file is a replayable session for the whole of its `exp`. `reason` below is a
|
|
17
|
+
* fixed enum and the caught error contributes only its CLASS NAME — jose's messages are
|
|
18
|
+
* safe today, but "safe today" is not a property worth betting a credential on.
|
|
19
|
+
*
|
|
20
|
+
* Guide, and the Caddyfile that calls this: docs/access-auth.md.
|
|
21
|
+
*/
|
|
22
|
+
import { type AccessIdentity, type AccessOptions, verifyAccessToken } from '../access.ts';
|
|
23
|
+
import { type Logger, log } from '../log.ts';
|
|
24
|
+
import { type TokenSource, accessToken } from './token.ts';
|
|
25
|
+
|
|
26
|
+
/** Why a request was refused. A closed set, so it is safe to log and safe to alert on. */
|
|
27
|
+
export type DenyReason =
|
|
28
|
+
| 'no-token'
|
|
29
|
+
| 'invalid-token'
|
|
30
|
+
| 'jwks-unavailable'
|
|
31
|
+
| 'not-authorized'
|
|
32
|
+
| 'unexpected';
|
|
33
|
+
|
|
34
|
+
/** The headers the upstream receives on success. Values are always strings, never absent. */
|
|
35
|
+
export interface IdentityHeaders {
|
|
36
|
+
/** @default 'X-Access-Email' */
|
|
37
|
+
readonly email?: string;
|
|
38
|
+
/** @default 'X-Access-Sub' */
|
|
39
|
+
readonly sub?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AccessForwardAuthOptions extends AccessOptions {
|
|
43
|
+
/** Rename the identity headers to match an upstream that expects its own names. */
|
|
44
|
+
readonly headers?: IdentityHeaders;
|
|
45
|
+
/**
|
|
46
|
+
* An extra check on an already-verified identity. `false` is a 403, not a 401.
|
|
47
|
+
* ⚠️ AUTHORIZATION, NOT AUTHENTICATION. The Access policy is the real gate; this is for
|
|
48
|
+
* an upstream that additionally wants, say, one team. Do not use it to re-check `aud`.
|
|
49
|
+
*/
|
|
50
|
+
readonly authorize?: (identity: AccessIdentity) => boolean | Promise<boolean>;
|
|
51
|
+
/** @default the package logger */
|
|
52
|
+
readonly logger?: Logger;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_HEADERS = { email: 'X-Access-Email', sub: 'X-Access-Sub' } as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ⛔ A JWKS FAILURE MUST NOT READ AS A BAD TOKEN. They need opposite responses from an
|
|
59
|
+
* operator — rotate nothing versus page someone — and they are indistinguishable in the
|
|
60
|
+
* caller's 401. jose surfaces the breaker's error as the `cause` of a JWKS error, so the
|
|
61
|
+
* class names of both the error and its cause are checked.
|
|
62
|
+
*/
|
|
63
|
+
function reasonFor(error: unknown): DenyReason {
|
|
64
|
+
// ⚠️ WALK THE `cause` CHAIN. jose wraps a transport failure, so the interesting error is
|
|
65
|
+
// rarely the outermost one — matching only on `error.name` reports every JWKS outage
|
|
66
|
+
// as a bad token, which is the diagnosis that sends an operator to the wrong system.
|
|
67
|
+
for (let cur: unknown = error, hops = 0; cur instanceof Error && hops < 4; hops += 1) {
|
|
68
|
+
// The breaker in ../jwks-breaker.ts throws exactly this for a non-200 certs endpoint.
|
|
69
|
+
if (cur.message.includes('JWKS endpoint answered')) return 'jwks-unavailable';
|
|
70
|
+
// `JWKSTimeout` is jose's; a bare `TypeError` from the fetch seam is "network refused".
|
|
71
|
+
if (cur.name === 'JWKSTimeout' || cur.name === 'TypeError') return 'jwks-unavailable';
|
|
72
|
+
cur = cur.cause;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ⛔ EVERYTHING ELSE IS A BAD TOKEN, INCLUDING `JWKSNoMatchingKey`. An unknown `kid` from
|
|
76
|
+
// a healthy endpoint means the token was signed by a key this team does not publish —
|
|
77
|
+
// that is a forgery or a long-rotated token, never an outage. Classing it as an outage
|
|
78
|
+
// would hide a forged token in the alert that says "Cloudflare is down".
|
|
79
|
+
return 'invalid-token';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function errorClass(error: unknown): string {
|
|
83
|
+
return error instanceof Error ? error.name : typeof error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** ⛔ No body, no `WWW-Authenticate` detail, no reason. An unauthenticated caller learns
|
|
87
|
+
* nothing about why; the reason goes to the log, where an operator can read it. */
|
|
88
|
+
function deny(status: 401 | 403): Response {
|
|
89
|
+
return new Response(null, { status });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the `fetch` handler Caddy's `forward_auth` calls.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* const verify = accessForwardAuth({
|
|
97
|
+
* teamDomain: 'https://example.cloudflareaccess.com',
|
|
98
|
+
* audience: process.env.ACCESS_AUD,
|
|
99
|
+
* });
|
|
100
|
+
* Bun.serve({ port: 9101, hostname: '127.0.0.1', fetch: verify });
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* ⛔ ONE HANDLER PER ACCESS APPLICATION. `audience` is fixed at construction and is never
|
|
104
|
+
* read from the request — an AUD a caller can influence is the whole vulnerability this
|
|
105
|
+
* exists to prevent. Protecting two applications means two handlers and two routes.
|
|
106
|
+
*
|
|
107
|
+
* ★ RETURNS A PLAIN `(Request) => Promise<Response>`, so it needs no server framework and
|
|
108
|
+
* no runtime-specific import: `Bun.serve`, `node:http`'s fetch adapters and workerd all
|
|
109
|
+
* take it as-is. Routing several of these by path is the consumer's own `if`.
|
|
110
|
+
*/
|
|
111
|
+
export function accessForwardAuth(
|
|
112
|
+
options: AccessForwardAuthOptions,
|
|
113
|
+
): (request: Request) => Promise<Response> {
|
|
114
|
+
const names = { ...DEFAULT_HEADERS, ...options.headers };
|
|
115
|
+
const logger = options.logger ?? log;
|
|
116
|
+
assertTeamDomain(options.teamDomain);
|
|
117
|
+
|
|
118
|
+
return async (request: Request): Promise<Response> => {
|
|
119
|
+
const found = accessToken(request);
|
|
120
|
+
if (found === undefined) {
|
|
121
|
+
logger.warn('access denied', { reason: 'no-token' satisfies DenyReason });
|
|
122
|
+
return deny(401);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let identity: AccessIdentity;
|
|
126
|
+
try {
|
|
127
|
+
identity = await verifyAccessToken(found.token, options);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const reason = reasonFor(error);
|
|
130
|
+
logger.warn('access denied', { reason, source: found.source, error: errorClass(error) });
|
|
131
|
+
return deny(401);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
if (options.authorize !== undefined && !(await options.authorize(identity))) {
|
|
136
|
+
// ⚠️ 403, not 401: re-authenticating cannot help, and a 401 sends the browser back
|
|
137
|
+
// to Access for a login it already completed — an infinite redirect loop.
|
|
138
|
+
logger.warn('access denied', {
|
|
139
|
+
reason: 'not-authorized' satisfies DenyReason,
|
|
140
|
+
email: identity.email ?? '',
|
|
141
|
+
});
|
|
142
|
+
return deny(403);
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// ⛔ A THROWING `authorize` IS A DENIAL, NOT A GRANT. A consumer's callback that hits
|
|
146
|
+
// its own database and fails must not fall through to the success path; letting the
|
|
147
|
+
// exception escape would hand Caddy a 502, which is also closed but arrives with no
|
|
148
|
+
// log line saying whose callback broke.
|
|
149
|
+
logger.error('access denied', {
|
|
150
|
+
reason: 'unexpected' satisfies DenyReason,
|
|
151
|
+
error: errorClass(error),
|
|
152
|
+
});
|
|
153
|
+
return deny(403);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return granted(identity, names, found.source);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* ⛔ EVERY IDENTITY HEADER IS SET ON EVERY 2xx, empty string included. This is the
|
|
162
|
+
* spoofing defence, and it is not optional.
|
|
163
|
+
* 🔴 Caddy's `copy_headers` expands to `request_header <Name> {rp.header.<Name>}` inside
|
|
164
|
+
* a 2xx handler — it copies FROM the auth response ONTO the client's request. A client
|
|
165
|
+
* that sends `X-Access-Email: admin@example.com` itself is overwritten only if this
|
|
166
|
+
* response carries that header. Omitting it for a token with no `email` claim would
|
|
167
|
+
* leave the attacker's value standing, and the upstream would read it as identity.
|
|
168
|
+
* Always emitting the name closes that whether or not Caddy also clears absent fields
|
|
169
|
+
* — behaviour its documentation does not actually promise.
|
|
170
|
+
*/
|
|
171
|
+
function granted(
|
|
172
|
+
identity: AccessIdentity,
|
|
173
|
+
names: Required<IdentityHeaders>,
|
|
174
|
+
source: TokenSource,
|
|
175
|
+
): Response {
|
|
176
|
+
const headers = new Headers();
|
|
177
|
+
headers.set(names.email, identity.email ?? '');
|
|
178
|
+
headers.set(names.sub, identity.sub ?? '');
|
|
179
|
+
headers.set('X-Access-Token-Source', source);
|
|
180
|
+
return new Response(null, { status: 204, headers });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* ⚠️ A TRAILING SLASH BREAKS BOTH CHECKS AT ONCE, AND QUIETLY. `teamDomain` is used
|
|
185
|
+
* verbatim as the expected `iss` and as the JWKS origin, so `https://x.cloudflareaccess.com/`
|
|
186
|
+
* yields an `iss` that never matches any real token AND a double-slashed certs URL. The
|
|
187
|
+
* symptom is every token failing verification with no clue pointing at the config.
|
|
188
|
+
*/
|
|
189
|
+
function assertTeamDomain(teamDomain: string): void {
|
|
190
|
+
if (!teamDomain.startsWith('https://') || teamDomain.endsWith('/')) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`access-auth: teamDomain must be https:// with no trailing slash, got ${teamDomain}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@homeflare/cloudflare/access-auth` — a Cloudflare Access verifier for a reverse proxy's
|
|
3
|
+
* `forward_auth`, so a surface that is not a Worker can be protected by Access properly.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY THIS IS A SUBPATH AND NOT THE MAIN ENTRY. The package entry is the workerd layer.
|
|
6
|
+
* This handler is runtime-neutral — `Request` in, `Response` out, no platform import —
|
|
7
|
+
* because the process that runs it sits next to the proxy, typically under Bun or Node,
|
|
8
|
+
* not on workerd. A subpath keeps that opt-in explicit and leaves the entry unchanged.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ THE AUD IS THE WHOLE CHECK. Every Access application in one account is signed by the
|
|
11
|
+
* SAME team keys, so a token minted for a different app in your own account verifies
|
|
12
|
+
* perfectly against issuer and signature. Only `audience` says the token was minted for
|
|
13
|
+
* THIS application. A verifier that checks `iss` alone admits every Access user of every
|
|
14
|
+
* app you run — which reads as working, for everyone, until the wrong person tries.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ THIS PROTECTS THE PROXY HOP, NOT THE ORIGIN PORT. Verifying the JWT is worth nothing
|
|
17
|
+
* if the upstream is also reachable directly; bind it to loopback, or the check is an
|
|
18
|
+
* ornament on a door that is propped open. docs/access-auth.md says it again with the
|
|
19
|
+
* Caddyfile.
|
|
20
|
+
*/
|
|
21
|
+
export {
|
|
22
|
+
accessForwardAuth,
|
|
23
|
+
type AccessForwardAuthOptions,
|
|
24
|
+
type DenyReason,
|
|
25
|
+
type IdentityHeaders,
|
|
26
|
+
} from './handler.ts';
|
|
27
|
+
export {
|
|
28
|
+
ACCESS_COOKIE,
|
|
29
|
+
ACCESS_HEADER,
|
|
30
|
+
accessToken,
|
|
31
|
+
readCookie,
|
|
32
|
+
type FoundToken,
|
|
33
|
+
type TokenSource,
|
|
34
|
+
} from './token.ts';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the Access token on an inbound request — header first, cookie second.
|
|
3
|
+
*
|
|
4
|
+
* ★ BOTH, BECAUSE NEITHER IS GUARANTEED. Cloudflare's validation guide says the
|
|
5
|
+
* `CF_Authorization` cookie "is not guaranteed to be passed" and to prefer the
|
|
6
|
+
* `Cf-Access-Jwt-Assertion` header. The reverse also happens: the header is attached by
|
|
7
|
+
* the Access hop, so a request that reaches the origin through a further proxy — or a
|
|
8
|
+
* browser replaying its own cookie — can carry the cookie and not the header. Checking
|
|
9
|
+
* one only is a 401 that looks like a broken login and is actually a missing branch.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THE HEADER WINS WHEN BOTH ARE PRESENT, and that order is load-bearing. The header is
|
|
12
|
+
* set by the Access hop on this request; the cookie is whatever the client's jar held.
|
|
13
|
+
* Preferring the cookie would let a client pin a stale token over the fresh assertion.
|
|
14
|
+
* ⚠️ Neither is trusted here — both are verified identically by `verifyAccessToken`.
|
|
15
|
+
* This is about which candidate is tried, not about which is believed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The header Access sets on the request it forwards to the origin. */
|
|
19
|
+
export const ACCESS_HEADER = 'Cf-Access-Jwt-Assertion';
|
|
20
|
+
|
|
21
|
+
/** The cookie Access sets in the browser. */
|
|
22
|
+
export const ACCESS_COOKIE = 'CF_Authorization';
|
|
23
|
+
|
|
24
|
+
/** Where a candidate token was found. Useful in a log line; never a trust signal. */
|
|
25
|
+
export type TokenSource = 'header' | 'cookie';
|
|
26
|
+
|
|
27
|
+
export interface FoundToken {
|
|
28
|
+
readonly token: string;
|
|
29
|
+
readonly source: TokenSource;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read one cookie out of a `Cookie` header value.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ HAND-PARSED, AND SMALL ON PURPOSE. A cookie library would bring parsing of
|
|
36
|
+
* attributes (Path, SameSite, Max-Age) that only ever appear on `Set-Cookie`, which is
|
|
37
|
+
* the response side. Requests carry a bare `name=value; name=value` list, and that is
|
|
38
|
+
* all this reads.
|
|
39
|
+
* ⚠️ A JWT is base64url and contains no `;` or `=`-in-value ambiguity beyond padding, but
|
|
40
|
+
* this still splits on the FIRST `=` only — a value containing `=` (base64 padding) is
|
|
41
|
+
* otherwise truncated mid-token, which fails verification with a signature error and
|
|
42
|
+
* sends you hunting in the wrong place.
|
|
43
|
+
*/
|
|
44
|
+
export function readCookie(header: string | null, name: string): string | undefined {
|
|
45
|
+
if (header === null) return undefined;
|
|
46
|
+
|
|
47
|
+
for (const pair of header.split(';')) {
|
|
48
|
+
const eq = pair.indexOf('=');
|
|
49
|
+
if (eq === -1) continue;
|
|
50
|
+
if (pair.slice(0, eq).trim() !== name) continue;
|
|
51
|
+
|
|
52
|
+
const value = pair.slice(eq + 1).trim();
|
|
53
|
+
return value === '' ? undefined : value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The Access token on `request`, or `undefined` when it carries none.
|
|
61
|
+
*
|
|
62
|
+
* ⛔ RETURNS THE CANDIDATE UNVERIFIED. Every caller must hand the result to
|
|
63
|
+
* `verifyAccessToken`; nothing about being present in a header makes a token valid.
|
|
64
|
+
*/
|
|
65
|
+
export function accessToken(request: Request): FoundToken | undefined {
|
|
66
|
+
const fromHeader = request.headers.get(ACCESS_HEADER);
|
|
67
|
+
if (fromHeader !== null && fromHeader !== '') return { token: fromHeader, source: 'header' };
|
|
68
|
+
|
|
69
|
+
const fromCookie = readCookie(request.headers.get('Cookie'), ACCESS_COOKIE);
|
|
70
|
+
if (fromCookie !== undefined) return { token: fromCookie, source: 'cookie' };
|
|
71
|
+
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
package/src/access.ts
CHANGED
|
@@ -29,6 +29,16 @@ export interface AccessOptions {
|
|
|
29
29
|
readonly teamDomain: string;
|
|
30
30
|
/** The application's AUD tag, from Zero Trust → Access → Applications. */
|
|
31
31
|
readonly audience: string;
|
|
32
|
+
/**
|
|
33
|
+
* Reject a token older than this, by `iat`. Seconds, or a jose duration like `'8h'`.
|
|
34
|
+
* ★ OPT-IN BECAUSE `exp` ALREADY BOUNDS THE TOKEN. Access sets the session lifetime in
|
|
35
|
+
* `exp`, so this is a second, shorter leash for a surface that wants one — not a
|
|
36
|
+
* missing check. ⚠️ Setting it below the Access session duration logs users out
|
|
37
|
+
* mid-session with a 401 they cannot fix by retrying.
|
|
38
|
+
*/
|
|
39
|
+
readonly maxTokenAge?: string | number;
|
|
40
|
+
/** Seconds of clock skew tolerated on `exp`/`nbf`/`iat`. jose's default is 0. */
|
|
41
|
+
readonly clockTolerance?: string | number;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
/** The subset of Access claims worth depending on. */
|
|
@@ -79,9 +89,35 @@ export async function verifyAccessJwt(
|
|
|
79
89
|
const token = request.headers.get(ACCESS_HEADER);
|
|
80
90
|
if (token === null) throw new Error('access: no Cf-Access-Jwt-Assertion header');
|
|
81
91
|
|
|
92
|
+
return await verifyAccessToken(token, options);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Verify a token string that the caller has already located, returning the identity.
|
|
97
|
+
*
|
|
98
|
+
* ★ EXTRACTED SO THERE IS EXACTLY ONE VERIFICATION, NOT TWO. `verifyAccessJwt` reads the
|
|
99
|
+
* header; the forward_auth handler (`@homeflare/cloudflare/access-auth`) also accepts
|
|
100
|
+
* the `CF_Authorization` cookie, because Cloudflare's own documentation says the cookie
|
|
101
|
+
* "is not guaranteed to be passed" and the reverse is true too — a request that arrives
|
|
102
|
+
* through a proxy hop may carry one and not the other. Both funnel here, so a fix to the
|
|
103
|
+
* checks below cannot land on one path and miss the other.
|
|
104
|
+
*
|
|
105
|
+
* ⛔ THROWS ON EVERY FAILURE, exactly like `verifyAccessJwt`. Same reasoning: a boolean a
|
|
106
|
+
* caller forgets to test is an open door.
|
|
107
|
+
*/
|
|
108
|
+
export async function verifyAccessToken(
|
|
109
|
+
token: string,
|
|
110
|
+
options: AccessOptions,
|
|
111
|
+
): Promise<AccessIdentity> {
|
|
82
112
|
const { payload } = await jwtVerify(token, keysFor(options.teamDomain), {
|
|
83
113
|
issuer: options.teamDomain,
|
|
84
114
|
audience: options.audience,
|
|
115
|
+
// ⛔ SPREAD, NOT `maxTokenAge: options.maxTokenAge`. `exactOptionalPropertyTypes` is on,
|
|
116
|
+
// and passing an explicit `undefined` is not the same as omitting the key — jose
|
|
117
|
+
// reads `'maxTokenAge' in options`, so the undefined form would enable the check
|
|
118
|
+
// with an undefined bound.
|
|
119
|
+
...(options.maxTokenAge === undefined ? {} : { maxTokenAge: options.maxTokenAge }),
|
|
120
|
+
...(options.clockTolerance === undefined ? {} : { clockTolerance: options.clockTolerance }),
|
|
85
121
|
});
|
|
86
122
|
|
|
87
123
|
return {
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,19 @@ export {
|
|
|
26
26
|
type AccessContext,
|
|
27
27
|
type AccessIdentityInfo,
|
|
28
28
|
} from './access-identity.ts';
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* ⚠️ `verifyAccessToken` IS THE SAME CHECK WITH THE TOKEN ALREADY IN HAND — for a caller
|
|
31
|
+
* that read it from somewhere other than the header (a cookie, a WebSocket subprotocol).
|
|
32
|
+
* It is not a lighter variant: identical issuer, audience and expiry checks.
|
|
33
|
+
* ★ Protecting a non-Worker origin behind a reverse proxy? `@homeflare/cloudflare/access-auth`
|
|
34
|
+
* is the ready-made `forward_auth` handler; you do not need to assemble one from these.
|
|
35
|
+
*/
|
|
36
|
+
export {
|
|
37
|
+
verifyAccessJwt,
|
|
38
|
+
verifyAccessToken,
|
|
39
|
+
type AccessIdentity,
|
|
40
|
+
type AccessOptions,
|
|
41
|
+
} from './access.ts';
|
|
30
42
|
export {
|
|
31
43
|
protectedResourceMetadata,
|
|
32
44
|
serveMcpMetadata,
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.
|
|
2
|
-
export const VERSION: string = '0.3.
|
|
2
|
+
export const VERSION: string = '0.3.2';
|