@homeflare/cloudflare 0.2.0 → 0.2.1
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/index.js +2 -2
- package/dist/index.js.map +2 -2
- package/package.json +2 -14
- package/src/version.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/version.ts
|
|
2
|
-
var VERSION = "0.2.
|
|
2
|
+
var VERSION = "0.2.1";
|
|
3
3
|
// src/access.ts
|
|
4
4
|
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
5
5
|
|
|
@@ -84,5 +84,5 @@ export {
|
|
|
84
84
|
verifyAccessJwt
|
|
85
85
|
};
|
|
86
86
|
|
|
87
|
-
//# debugId=
|
|
87
|
+
//# debugId=A5C73238F81818A764756E2164756E21
|
|
88
88
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"version": 3,
|
|
3
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.2.
|
|
5
|
+
"// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.2.1';\n",
|
|
6
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
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",
|
|
8
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"
|
|
9
9
|
],
|
|
10
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": "
|
|
11
|
+
"debugId": "A5C73238F81818A764756E2164756E21",
|
|
12
12
|
"names": []
|
|
13
13
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/cloudflare",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -29,20 +29,8 @@
|
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
|
-
"scripts": {
|
|
33
|
-
"build": "bun run build:js && bun run build:types",
|
|
34
|
-
"build:js": "bun build ./src/index.ts --outdir dist --target node --format esm --packages external --sourcemap=linked",
|
|
35
|
-
"build:types": "tsc --project tsconfig.build.json",
|
|
36
|
-
"clean": "rm -rf dist",
|
|
37
|
-
"test": "bun test",
|
|
38
|
-
"smoke": "echo 'cloudflare: smoke covered by @homeflare/kit'",
|
|
39
|
-
"types": "tsc --noEmit"
|
|
40
|
-
},
|
|
41
32
|
"dependencies": {
|
|
42
|
-
"@homeflare/kit": "0.2.
|
|
33
|
+
"@homeflare/kit": "0.2.1",
|
|
43
34
|
"jose": "6.2.12"
|
|
44
|
-
},
|
|
45
|
-
"devDependencies": {
|
|
46
|
-
"@cloudflare/workers-types": "5.20260908.1"
|
|
47
35
|
}
|
|
48
36
|
}
|
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.
|
|
2
|
+
export const VERSION: string = '0.2.1';
|