@homeflare/cloudflare 0.1.2 → 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Timothy Schneider <tim@taslabs.net>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @homeflare/cloudflare
2
+
3
+ Cloudflare Workers helpers: Access JWT verification and structured logging.
4
+
5
+ ```sh
6
+ bun add @homeflare/cloudflare
7
+ ```
8
+
9
+ ⛔ **Workers only.** This package assumes workerd. For code that must also run on Node or
10
+ in a script, use [`@homeflare/kit`](https://www.npmjs.com/package/@homeflare/kit).
11
+
12
+ ## verifyAccessJwt
13
+
14
+ ```ts
15
+ import { verifyAccessJwt } from '@homeflare/cloudflare';
16
+
17
+ const who = await verifyAccessJwt(request, {
18
+ teamDomain: env.TEAM_DOMAIN, // https://<team>.cloudflareaccess.com
19
+ audience: env.POLICY_AUD, // the app's AUD tag
20
+ });
21
+ who.email; // string | undefined
22
+ who.sub; // string | undefined
23
+ who.claims; // everything else
24
+ ```
25
+
26
+ ⛔ **Never omit `audience`.** Any Access app in the account mints a JWT the team domain
27
+ will happily verify; the AUD tag is the only thing saying the token was minted for _this_
28
+ app.
29
+
30
+ Throws on a missing header, bad signature, wrong audience, or expiry — deliberately, so a
31
+ forgotten `if (!ok)` cannot become an open door. The JWKS is cached per isolate, so it is
32
+ not refetched per request.
33
+
34
+ ## log
35
+
36
+ ```ts
37
+ import { log } from '@homeflare/cloudflare';
38
+
39
+ const rlog = log.with({ requestId, path: url.pathname });
40
+ rlog.info('handled', { status, ms }); // also .debug .warn .error
41
+ ```
42
+
43
+ Writes one JSON object per line, which Workers Logs indexes field by field. No logging
44
+ library: `console.log` of JSON _is_ the integration.
45
+
46
+ ⚠️ One object, never `(message, fields)` — multiple arguments are stringified and
47
+ concatenated, which destroys the structure Workers Logs would index.
48
+
49
+ ⛔ **Never log a credential.** There is no redaction layer, by design: a redactor implies
50
+ it is safe to pass secrets in, then quietly misses one.
51
+
52
+ ## Two kinds of auth
53
+
54
+ This package verifies **Cloudflare Access** — the edge already authenticated the caller.
55
+ If _you_ are the identity provider (sessions, accounts, a database), that is
56
+ [`@homeflare/auth`](https://www.npmjs.com/package/@homeflare/auth). Reaching for the
57
+ wrong one produces a system that looks authenticated and is not.
58
+
59
+ ## License
60
+
61
+ MIT © Timothy Schneider
@@ -1 +1 @@
1
- {"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;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;AAoBD;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,cAAc,CAAC,CAczB"}
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;CAC3B;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,CAczB"}
package/dist/index.d.ts CHANGED
@@ -10,4 +10,5 @@
10
10
  export { VERSION } from './version.ts';
11
11
  export { verifyAccessJwt, type AccessIdentity, type AccessOptions } from './access.ts';
12
12
  export { log, type LogFields, type Logger } from './log.ts';
13
+ export { BREAKER_COOLDOWN_MS, breakered } from './jwks-breaker.ts';
13
14
  //# sourceMappingURL=index.d.ts.map
@@ -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,OAAO,EAAE,eAAe,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACvF,OAAO,EAAE,GAAG,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC"}
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,OAAO,EAAE,eAAe,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACvF,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,14 +1,44 @@
1
1
  // src/version.ts
2
- var VERSION = "0.1.2";
2
+ var VERSION = "0.2.0";
3
+ // src/access.ts
4
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
5
+
6
+ // src/jwks-breaker.ts
7
+ var BREAKER_COOLDOWN_MS = 30000;
8
+ var failures = new Map;
9
+ function breakered(inner = (url, options) => fetch(url, options), cooldownMs = BREAKER_COOLDOWN_MS) {
10
+ return async (url, options) => {
11
+ const open = failures.get(url);
12
+ if (open !== undefined && Date.now() - open.at < cooldownMs) {
13
+ throw open.error;
14
+ }
15
+ try {
16
+ const response = await inner(url, options);
17
+ if (!response.ok) {
18
+ const error = new Error(`JWKS endpoint answered ${String(response.status)}: ${url}`);
19
+ failures.set(url, { at: Date.now(), error });
20
+ throw error;
21
+ }
22
+ failures.delete(url);
23
+ return response;
24
+ } catch (cause) {
25
+ const error = cause instanceof Error ? cause : new Error(String(cause));
26
+ failures.set(url, { at: Date.now(), error });
27
+ throw error;
28
+ }
29
+ };
30
+ }
31
+
3
32
  // src/access.ts
4
- import { createRemoteJWKSet, jwtVerify } from "jose";
5
33
  var ACCESS_HEADER = "cf-access-jwt-assertion";
6
34
  var jwks = new Map;
7
35
  function keysFor(teamDomain) {
8
36
  const existing = jwks.get(teamDomain);
9
37
  if (existing !== undefined)
10
38
  return existing;
11
- const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`));
39
+ const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`), {
40
+ [customFetch]: breakered()
41
+ });
12
42
  jwks.set(teamDomain, created);
13
43
  return created;
14
44
  }
@@ -47,10 +77,12 @@ function make(bound) {
47
77
  }
48
78
  var log = make({});
49
79
  export {
80
+ BREAKER_COOLDOWN_MS,
50
81
  VERSION,
82
+ breakered,
51
83
  log,
52
84
  verifyAccessJwt
53
85
  };
54
86
 
55
- //# debugId=DEF7AC9FDEC3F4B864756E2164756E21
87
+ //# debugId=BD3B009198FBDF0464756E2164756E21
56
88
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/version.ts", "../src/access.ts", "../src/log.ts"],
3
+ "sources": ["../src/version.ts", "../src/access.ts", "../src/jwks-breaker.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.1.2';\n",
6
- "/**\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, jwtVerify } from 'jose';\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 const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`));\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",
5
+ "// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.2.0';\n",
6
+ "/**\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 * 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
8
  "/**\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
9
  ],
9
- "mappings": ";AACO,IAAM,UAAkB;;ACmB/B;AAGA,IAAM,gBAAgB;AAwBtB,IAAM,OAAO,IAAI;AAEjB,SAAS,OAAO,CAAC,YAA2D;AAAA,EAC1E,MAAM,WAAW,KAAK,IAAI,UAAU;AAAA,EACpC,IAAI,aAAa;AAAA,IAAW,OAAO;AAAA,EAEnC,MAAM,UAAU,mBAAmB,IAAI,IAAI,GAAG,iCAAiC,CAAC;AAAA,EAChF,KAAK,IAAI,YAAY,OAAO;AAAA,EAC5B,OAAO;AAAA;AAWT,eAAsB,eAAe,CACnC,SACA,SACyB;AAAA,EACzB,MAAM,QAAQ,QAAQ,QAAQ,IAAI,aAAa;AAAA,EAC/C,IAAI,UAAU;AAAA,IAAM,MAAM,IAAI,MAAM,2CAA2C;AAAA,EAE/E,QAAQ,YAAY,MAAM,UAAU,OAAO,QAAQ,QAAQ,UAAU,GAAG;AAAA,IACtE,QAAQ,QAAQ;AAAA,IAChB,UAAU,QAAQ;AAAA,EACpB,CAAC;AAAA,EAED,OAAO;AAAA,IACL,OAAO,OAAO,QAAQ,aAAa,WAAW,QAAQ,WAAW;AAAA,IACjE,KAAK,QAAQ;AAAA,IACb,QAAQ;AAAA,EACV;AAAA;;ACrDF,SAAS,IAAI,CAAC,OAAc,SAAiB,QAAyB;AAAA,EAKpE,MAAM,OAAO,EAAE,OAAO,YAAY,OAAO;AAAA,EAEzC,IAAI,UAAU;AAAA,IAAS,QAAQ,MAAM,IAAI;AAAA,EACpC,SAAI,UAAU;AAAA,IAAQ,QAAQ,KAAK,IAAI;AAAA,EACvC;AAAA,YAAQ,IAAI,IAAI;AAAA;AAGvB,SAAS,IAAI,CAAC,OAA0B;AAAA,EACtC,OAAO;AAAA,IACL,OAAO,CAAC,SAAS,WAAW,KAAK,SAAS,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IAC1E,MAAM,CAAC,SAAS,WAAW,KAAK,QAAQ,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IACxE,MAAM,CAAC,SAAS,WAAW,KAAK,QAAQ,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IACxE,OAAO,CAAC,SAAS,WAAW,KAAK,SAAS,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IAC1E,MAAM,CAAC,UAAU,KAAK,KAAK,UAAU,MAAM,CAAC;AAAA,EAC9C;AAAA;AASK,IAAM,MAAc,KAAK,CAAC,CAAC;",
10
- "debugId": "DEF7AC9FDEC3F4B864756E2164756E21",
10
+ "mappings": ";AACO,IAAM,UAAkB;;ACmB/B;;;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;AAwBtB,IAAM,OAAO,IAAI;AAEjB,SAAS,OAAO,CAAC,YAA2D;AAAA,EAC1E,MAAM,WAAW,KAAK,IAAI,UAAU;AAAA,EACpC,IAAI,aAAa;AAAA,IAAW,OAAO;AAAA,EAOnC,MAAM,UAAU,mBAAmB,IAAI,IAAI,GAAG,iCAAiC,GAAG;AAAA,KAC/E,cAAc,UAAU;AAAA,EAC3B,CAAC;AAAA,EACD,KAAK,IAAI,YAAY,OAAO;AAAA,EAC5B,OAAO;AAAA;AAWT,eAAsB,eAAe,CACnC,SACA,SACyB;AAAA,EACzB,MAAM,QAAQ,QAAQ,QAAQ,IAAI,aAAa;AAAA,EAC/C,IAAI,UAAU;AAAA,IAAM,MAAM,IAAI,MAAM,2CAA2C;AAAA,EAE/E,QAAQ,YAAY,MAAM,UAAU,OAAO,QAAQ,QAAQ,UAAU,GAAG;AAAA,IACtE,QAAQ,QAAQ;AAAA,IAChB,UAAU,QAAQ;AAAA,EACpB,CAAC;AAAA,EAED,OAAO;AAAA,IACL,OAAO,OAAO,QAAQ,aAAa,WAAW,QAAQ,WAAW;AAAA,IACjE,KAAK,QAAQ;AAAA,IACb,QAAQ;AAAA,EACV;AAAA;;AE7DF,SAAS,IAAI,CAAC,OAAc,SAAiB,QAAyB;AAAA,EAKpE,MAAM,OAAO,EAAE,OAAO,YAAY,OAAO;AAAA,EAEzC,IAAI,UAAU;AAAA,IAAS,QAAQ,MAAM,IAAI;AAAA,EACpC,SAAI,UAAU;AAAA,IAAQ,QAAQ,KAAK,IAAI;AAAA,EACvC;AAAA,YAAQ,IAAI,IAAI;AAAA;AAGvB,SAAS,IAAI,CAAC,OAA0B;AAAA,EACtC,OAAO;AAAA,IACL,OAAO,CAAC,SAAS,WAAW,KAAK,SAAS,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IAC1E,MAAM,CAAC,SAAS,WAAW,KAAK,QAAQ,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IACxE,MAAM,CAAC,SAAS,WAAW,KAAK,QAAQ,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IACxE,OAAO,CAAC,SAAS,WAAW,KAAK,SAAS,SAAS,KAAK,UAAU,OAAO,CAAC;AAAA,IAC1E,MAAM,CAAC,UAAU,KAAK,KAAK,UAAU,MAAM,CAAC;AAAA,EAC9C;AAAA;AASK,IAAM,MAAc,KAAK,CAAC,CAAC;",
11
+ "debugId": "BD3B009198FBDF0464756E2164756E21",
11
12
  "names": []
12
13
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A circuit breaker for JWKS fetches, installed at jose's own `customFetch` seam.
3
+ *
4
+ * ⛔ THE AMPLIFICATION THIS CLOSES, MEASURED ON jose 6.2.12 — the version this package
5
+ * pins, not an old one:
6
+ *
7
+ * 5 verifications of a well-formed token, certs endpoint 404ing → 5 outbound fetches
8
+ *
9
+ * jose assigns `jwksTimestamp` only inside the `.then()` of a SUCCESSFUL fetch
10
+ * (`dist/webapi/jwks/remote.js`, line 58). While an endpoint is down, `local` stays
11
+ * undefined, so `if (!local || !isFreshFor(jwksTimestamp, cacheMaxAge)) await reload()`
12
+ * runs on EVERY call. A down Access endpoint therefore turns every inbound request into
13
+ * an outbound fetch, indefinitely.
14
+ *
15
+ * ★ NOT A REIMPLEMENTATION. `customFetch` is jose's exported, documented extension point;
16
+ * jose keeps doing the caching, cooldown, `kid` matching and verification. This adds the
17
+ * one thing it does not do — remember that a fetch FAILED.
18
+ * ⚠️ Measured first: neither `cacheMaxAge: Infinity` nor `cooldownDuration` nor both
19
+ * together change the count. It is 5 fetches in every configuration, so this is not a
20
+ * matter of configuring jose correctly.
21
+ *
22
+ * ⛔ THE BREAKER STORES PLAIN VALUES ONLY — a timestamp and an Error. NEVER a Response, a
23
+ * body stream, or a promise of one.
24
+ * 🔴 On workerd, a Response and its body belong to the IoContext of the request that
25
+ * created them, and another request touching either throws:
26
+ * Cannot perform I/O on behalf of a different request.
27
+ * Caching a Response across requests would therefore turn a VALID token into a flat
28
+ * 401 for every caller but the first — an outage strictly worse than the amplification
29
+ * it was meant to fix. Timestamps and Errors cross requests legally; I/O objects do not.
30
+ */
31
+ import type { FetchImplementation } from 'jose';
32
+ /** How long a URL stays latched open after a failed probe. */
33
+ export declare const BREAKER_COOLDOWN_MS = 30000;
34
+ /** Visible for testing. ⛔ Not exported from the package — resetting it in production
35
+ * would discard exactly the memory this exists to keep. */
36
+ export declare function resetBreaker(): void;
37
+ /**
38
+ * Wrap a fetch so a failing URL is not retried for `cooldownMs`.
39
+ *
40
+ * ★ FAILURE IS COUNTED AT THE SEAM, not inferred from an error class further up. jose
41
+ * throws `JWKSNoMatchingKey` as a NORMAL answer from a perfectly healthy endpoint — a
42
+ * token signed with a rotated key does that — so treating it as a fault would latch a
43
+ * working endpoint closed and reject every valid token for 30 seconds.
44
+ */
45
+ export declare function breakered(inner?: FetchImplementation, cooldownMs?: number): FetchImplementation;
46
+ //# sourceMappingURL=jwks-breaker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwks-breaker.d.ts","sourceRoot":"","sources":["../src/jwks-breaker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAEhD,8DAA8D;AAC9D,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAU1C;4DAC4D;AAC5D,wBAAgB,YAAY,IAAI,IAAI,CAEnC;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,KAAK,GAAE,mBAA2D,EAClE,UAAU,GAAE,MAA4B,GACvC,mBAAmB,CAkCrB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/cloudflare",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Cloudflare Workers helpers: Access JWT verification, typed bindings, 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",
package/src/access.ts CHANGED
@@ -18,7 +18,8 @@
18
18
  *
19
19
  * Docs: developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/
20
20
  */
21
- import { createRemoteJWKSet, jwtVerify } from 'jose';
21
+ import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
22
+ import { breakered } from './jwks-breaker.ts';
22
23
 
23
24
  /** The header Access puts the token in. Lowercase — Headers.get is case-insensitive. */
24
25
  const ACCESS_HEADER = 'cf-access-jwt-assertion';
@@ -51,7 +52,14 @@ function keysFor(teamDomain: string): ReturnType<typeof createRemoteJWKSet> {
51
52
  const existing = jwks.get(teamDomain);
52
53
  if (existing !== undefined) return existing;
53
54
 
54
- const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`));
55
+ // ⛔ THE BREAKER IS NOT OPTIONAL. jose refetches on EVERY call while an endpoint is
56
+ // down — measured on 6.2.12: 5 verifications against a 404ing certs endpoint made 5
57
+ // outbound fetches, and no combination of cacheMaxAge or cooldownDuration changes it.
58
+ // Without this, a Cloudflare Access outage turns every inbound request into an
59
+ // outbound one. See jwks-breaker.ts.
60
+ const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`), {
61
+ [customFetch]: breakered(),
62
+ });
55
63
  jwks.set(teamDomain, created);
56
64
  return created;
57
65
  }
package/src/index.ts CHANGED
@@ -11,3 +11,4 @@
11
11
  export { VERSION } from './version.ts';
12
12
  export { verifyAccessJwt, type AccessIdentity, type AccessOptions } from './access.ts';
13
13
  export { log, type LogFields, type Logger } from './log.ts';
14
+ export { BREAKER_COOLDOWN_MS, breakered } from './jwks-breaker.ts';
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A circuit breaker for JWKS fetches, installed at jose's own `customFetch` seam.
3
+ *
4
+ * ⛔ THE AMPLIFICATION THIS CLOSES, MEASURED ON jose 6.2.12 — the version this package
5
+ * pins, not an old one:
6
+ *
7
+ * 5 verifications of a well-formed token, certs endpoint 404ing → 5 outbound fetches
8
+ *
9
+ * jose assigns `jwksTimestamp` only inside the `.then()` of a SUCCESSFUL fetch
10
+ * (`dist/webapi/jwks/remote.js`, line 58). While an endpoint is down, `local` stays
11
+ * undefined, so `if (!local || !isFreshFor(jwksTimestamp, cacheMaxAge)) await reload()`
12
+ * runs on EVERY call. A down Access endpoint therefore turns every inbound request into
13
+ * an outbound fetch, indefinitely.
14
+ *
15
+ * ★ NOT A REIMPLEMENTATION. `customFetch` is jose's exported, documented extension point;
16
+ * jose keeps doing the caching, cooldown, `kid` matching and verification. This adds the
17
+ * one thing it does not do — remember that a fetch FAILED.
18
+ * ⚠️ Measured first: neither `cacheMaxAge: Infinity` nor `cooldownDuration` nor both
19
+ * together change the count. It is 5 fetches in every configuration, so this is not a
20
+ * matter of configuring jose correctly.
21
+ *
22
+ * ⛔ THE BREAKER STORES PLAIN VALUES ONLY — a timestamp and an Error. NEVER a Response, a
23
+ * body stream, or a promise of one.
24
+ * 🔴 On workerd, a Response and its body belong to the IoContext of the request that
25
+ * created them, and another request touching either throws:
26
+ * Cannot perform I/O on behalf of a different request.
27
+ * Caching a Response across requests would therefore turn a VALID token into a flat
28
+ * 401 for every caller but the first — an outage strictly worse than the amplification
29
+ * it was meant to fix. Timestamps and Errors cross requests legally; I/O objects do not.
30
+ */
31
+
32
+ import type { FetchImplementation } from 'jose';
33
+
34
+ /** How long a URL stays latched open after a failed probe. */
35
+ export const BREAKER_COOLDOWN_MS = 30_000;
36
+
37
+ type Failure = { readonly at: number; readonly error: Error };
38
+
39
+ /**
40
+ * ⚠️ Module scope is PER ISOLATE on workerd, which is the right lifetime: long enough to
41
+ * stop the storm, short enough that a recycled isolate retries a recovered endpoint.
42
+ */
43
+ const failures = new Map<string, Failure>();
44
+
45
+ /** Visible for testing. ⛔ Not exported from the package — resetting it in production
46
+ * would discard exactly the memory this exists to keep. */
47
+ export function resetBreaker(): void {
48
+ failures.clear();
49
+ }
50
+
51
+ /**
52
+ * Wrap a fetch so a failing URL is not retried for `cooldownMs`.
53
+ *
54
+ * ★ FAILURE IS COUNTED AT THE SEAM, not inferred from an error class further up. jose
55
+ * throws `JWKSNoMatchingKey` as a NORMAL answer from a perfectly healthy endpoint — a
56
+ * token signed with a rotated key does that — so treating it as a fault would latch a
57
+ * working endpoint closed and reject every valid token for 30 seconds.
58
+ */
59
+ export function breakered(
60
+ inner: FetchImplementation = (url, options) => fetch(url, options),
61
+ cooldownMs: number = BREAKER_COOLDOWN_MS,
62
+ ): FetchImplementation {
63
+ // ⚠️ jose's OWN FetchImplementation, not `typeof fetch`. It is narrower — a string URL
64
+ // and a fixed options shape — and using the global type instead fails to typecheck on
65
+ // `preconnect`, a property of the platform fetch that no seam implementation has.
66
+ return async (url, options) => {
67
+ const open = failures.get(url);
68
+
69
+ if (open !== undefined && Date.now() - open.at < cooldownMs) {
70
+ // ⚠️ Rethrow the ORIGINAL error, not a generic one: the first failure says whether
71
+ // this was a 404, a TLS problem or a timeout, and that is what an operator needs.
72
+ // A "circuit open" message alone hides the actual fault.
73
+ throw open.error;
74
+ }
75
+
76
+ try {
77
+ const response = await inner(url, options);
78
+
79
+ // ⛔ A non-200 is a failure here. jose requires 200 with a JSON body; a 404 or a 502
80
+ // is exactly the down-endpoint case, and it does NOT throw on its own.
81
+ if (!response.ok) {
82
+ const error = new Error(`JWKS endpoint answered ${String(response.status)}: ${url}`);
83
+ failures.set(url, { at: Date.now(), error });
84
+ throw error;
85
+ }
86
+
87
+ failures.delete(url);
88
+ return response;
89
+ } catch (cause) {
90
+ const error = cause instanceof Error ? cause : new Error(String(cause));
91
+ // `set` is idempotent here — a second failure simply restarts the cooldown.
92
+ failures.set(url, { at: Date.now(), error });
93
+ throw error;
94
+ }
95
+ };
96
+ }
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.1.2';
2
+ export const VERSION: string = '0.2.0';