@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 +21 -0
- package/README.md +61 -0
- package/dist/access.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -4
- package/dist/index.js.map +6 -5
- package/dist/jwks-breaker.d.ts +46 -0
- package/dist/jwks-breaker.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/access.ts +10 -2
- package/src/index.ts +1 -0
- package/src/jwks-breaker.ts +96 -0
- package/src/version.ts +1 -1
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
|
package/dist/access.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access.d.ts","sourceRoot":"","sources":["../src/access.ts"],"names":[],"mappings":"
|
|
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
|
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,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.
|
|
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=
|
|
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.
|
|
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;
|
|
10
|
-
"debugId": "
|
|
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.
|
|
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
|
-
|
|
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
|
@@ -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.
|
|
2
|
+
export const VERSION: string = '0.2.0';
|