@pithy-sh/core 0.3.1 → 0.4.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/capability/capability.d.ts +44 -0
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/http/oidcWebhook.d.ts +83 -27
- package/dist/http/oidcWebhook.d.ts.map +1 -1
- package/dist/http/oidcWebhook.js +111 -35
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +1 -1
- package/src/capability/capability.ts +45 -0
- package/src/http/oidcWebhook.ts +155 -40
- package/src/version.generated.ts +1 -1
|
@@ -153,6 +153,16 @@ export interface SecretRegistryEntrySeam {
|
|
|
153
153
|
* which it can do without executing the package.
|
|
154
154
|
*/
|
|
155
155
|
readonly devValue?: string;
|
|
156
|
+
/**
|
|
157
|
+
* The Worker binding this value exists to reach, or absent when it reaches none — the constant half
|
|
158
|
+
* of *can this composition use this secret* (#541).
|
|
159
|
+
*
|
|
160
|
+
* Read here and never interpreted, exactly like {@link devValue}: the CLI joins it against a Worker's
|
|
161
|
+
* `declinedBindings` so a credential whose bucket the project declined stops being listed as
|
|
162
|
+
* outstanding work. `@pithy-sh/secrets` owns the concrete field; core needs it on the seam so a
|
|
163
|
+
* reporting surface can read a capability's declared slice off this contract.
|
|
164
|
+
*/
|
|
165
|
+
readonly binding?: string;
|
|
156
166
|
}
|
|
157
167
|
/** A capability's secret-registry slice: secret name → {@link SecretRegistryEntrySeam}. */
|
|
158
168
|
export type SecretRegistrySeam = Record<string, SecretRegistryEntrySeam>;
|
|
@@ -183,6 +193,34 @@ export type SecretRegistrySeam = Record<string, SecretRegistryEntrySeam>;
|
|
|
183
193
|
* secret whose schema has no branches at all needs no declaration — every field is asked.
|
|
184
194
|
*/
|
|
185
195
|
export type SecretBranchSeam = Record<string, readonly string[]>;
|
|
196
|
+
/**
|
|
197
|
+
* **Which of this capability's secrets this Worker's configuration cannot reach** — secret name → the
|
|
198
|
+
* reason, in an operator's words.
|
|
199
|
+
*
|
|
200
|
+
* The neighbor of {@link SecretBranchSeam}, one level up. That one says which *blocks* of a bundle
|
|
201
|
+
* apply when the bundle itself does; this says that a whole secret does not. `@pithy-sh/auth` declares
|
|
202
|
+
* one credential per OAuth provider, so a project running Google and GitHub has two secrets that no
|
|
203
|
+
* code path will ever read — and until #541 `pithy doctor` listed both as outstanding work under
|
|
204
|
+
* *"fine to leave until you need it"*, which is not true of a credential the configuration has refused.
|
|
205
|
+
*
|
|
206
|
+
* **Declared, never inferred**, for {@link SecretBranchSeam}'s reason: `auth-apple-credentials` and
|
|
207
|
+
* `providers.apple` are spelled alike today and nothing makes a secret name and a config key the same
|
|
208
|
+
* thing. The capability holds both halves at construction; a CLI matching them by substring would be
|
|
209
|
+
* right by luck.
|
|
210
|
+
*
|
|
211
|
+
* **Per composition, which is why it is here and not on the registry entry.** A registry entry is plain
|
|
212
|
+
* data shared by every Worker that declares the name; whether a provider is enabled is one Worker's
|
|
213
|
+
* configuration. The constant half of the same question — *which binding does this credential exist to
|
|
214
|
+
* reach* — is on the entry, as `binding`, because it does not change between compositions.
|
|
215
|
+
*
|
|
216
|
+
* **The reason reaches a terminal, so it is a sentence and not a code.** Name the configuration that
|
|
217
|
+
* decided it, the way an operator would have to edit it: `providers.apple is not enabled in
|
|
218
|
+
* pithy.config.ts`. It answers *what would I have to do to turn this on* as well as *why is this here*.
|
|
219
|
+
*
|
|
220
|
+
* Declaring nothing is the ordinary case and means every declared secret applies. It is descriptive
|
|
221
|
+
* only: nothing in the runtime reads it, and no command acts on it beyond reporting.
|
|
222
|
+
*/
|
|
223
|
+
export type InapplicableSecretSeam = Record<string, string>;
|
|
186
224
|
/**
|
|
187
225
|
* The structural seam for one capability's token-profile slice — profile name → the scoped CF API
|
|
188
226
|
* token that capability's code needs. Declared next to the code that uses it (alongside
|
|
@@ -313,6 +351,12 @@ export interface Capability<Databases extends DatabaseSpecMap = DatabaseSpecMap,
|
|
|
313
351
|
* no `json` secret at all, declares nothing.
|
|
314
352
|
*/
|
|
315
353
|
secretBranches?: SecretBranchSeam;
|
|
354
|
+
/**
|
|
355
|
+
* Which of this capability's secrets this Worker's configuration cannot reach, and why — see
|
|
356
|
+
* {@link InapplicableSecretSeam}. Additive and optional: a capability whose secrets all apply
|
|
357
|
+
* whenever it is composed declares nothing, which is the ordinary case.
|
|
358
|
+
*/
|
|
359
|
+
inapplicableSecrets?: InapplicableSecretSeam;
|
|
316
360
|
/**
|
|
317
361
|
* The scoped CF API tokens this capability's code needs, as a token-profile slice (profile name →
|
|
318
362
|
* {@link TokenProfileSeam}). Additive and optional, and declared next to {@link Capability.secretRegistry}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capability.d.ts","sourceRoot":"","sources":["../../src/capability/capability.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAC3F,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AACjC,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAC7B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,yCAAyC,CAAC;AACxF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACzE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AACvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,KAAK,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,UAAU,CAAC;AAC1E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAErD,6GAA6G;AAC7G,MAAM,WAAW,SAAS;IACxB,kGAAkG;IAClG,IAAI,EAAE,WAAW,GAAG,IAAI,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IAC7B;;;;;;;OAOG;IACH,CAAC,EAAE,UAAU,CAAC;IACd;;;;;;;;;OASG;IACH,YAAY,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACzC;;;;;;;;OAQG;IACH,oBAAoB,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAClD;;;;;;;;;OASG;IACH,UAAU,EAAE,cAAc,GAAG,IAAI,CAAC;IAClC;;;;;OAKG;IACH,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;;OAQG;IACH,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;;;OAMG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,EAAE,EAAE,OAAO,CAAC;IACZ;;;;OAIG;IACH,EAAE,EAAE,OAAO,CAAC;IACZ;;;;;OAKG;IACH,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,iHAAiH;AACjH,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAEvF,MAAM,MAAM,eAAe,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;AAEhE;;;;;;;;GAQG;AACH,MAAM,WAAW,uBAAuB;IACtC,mDAAmD;IACnD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,8DAA8D;IAC9D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"capability.d.ts","sourceRoot":"","sources":["../../src/capability/capability.ts"],"names":[],"mappings":"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"}
|
|
@@ -46,17 +46,20 @@ import type { PithyHonoEnv } from "../capability/capability";
|
|
|
46
46
|
* ## What a refusal says
|
|
47
47
|
*
|
|
48
48
|
* `core/webhook_unverified`, 401, one code for every step a sender can fail — unreadable token, unknown key,
|
|
49
|
-
* bad signature, wrong audience, expired, predicate false.
|
|
49
|
+
* a key that cannot verify an `RS256` signature, bad signature, wrong audience, expired, predicate false. A
|
|
50
|
+
* `kid` selects the key, and a `kid` is a value the sender chose, so *every* answer the key leads to is the
|
|
51
|
+
* sender's. The step goes in `detail`, which the HTTP codec
|
|
50
52
|
* strips, so an operator reading a log learns which check refused it and the sender learns only that
|
|
51
53
|
* something did. Values that decide something are named there (`kid`, `iss`, `aud`) because a misconfigured
|
|
52
54
|
* audience is the realistic failure and an operator cannot fix what the log will not name — truncated by
|
|
53
55
|
* {@link snippet}, and never the signature and never the whole token.
|
|
54
56
|
*
|
|
55
|
-
* Two failures are deliberately **not** the sender's
|
|
56
|
-
* endpoint, or a clock that is not a clock is a
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* read *our* logs and the thing that broke
|
|
57
|
+
* Two failures are deliberately **not** the sender's, and the test for both is whether the sender could have
|
|
58
|
+
* chosen them. An endpoint with no issuers, no audience, no key endpoint, or a clock that is not a clock is a
|
|
59
|
+
* configuration fault and takes `core/internal` — reporting it as an unverified webhook sends an operator
|
|
60
|
+
* hunting a forger who is not there. And a key endpoint that cannot be reached or answers nonsense is
|
|
61
|
+
* `core/upstream_failed` (502), because a 500 tells an operator to read *our* logs and the thing that broke
|
|
62
|
+
* is the issuer's; no `kid` decides whether that happens. Both still refuse the delivery: fail closed is
|
|
60
63
|
* about what happens to the request, not about whose code goes on it.
|
|
61
64
|
*
|
|
62
65
|
* ## What this proves, and what it does not
|
|
@@ -65,8 +68,29 @@ import type { PithyHonoEnv } from "../capability/capability";
|
|
|
65
68
|
* never that it is *new*. A captured token replays until it expires. A handler that grants, charges, or
|
|
66
69
|
* deletes needs its own uniqueness key.
|
|
67
70
|
*/
|
|
68
|
-
/** How long a fetched key set is reused. Issuers rotate slowly,
|
|
71
|
+
/** How long a fetched key set is reused. Issuers rotate slowly, so an hour of it is one round trip an hour. */
|
|
69
72
|
export declare const OIDC_JWKS_TTL_SECONDS = 3600;
|
|
73
|
+
/**
|
|
74
|
+
* **The most often the issuer's key endpoint is asked, whatever a token's `kid` claims.**
|
|
75
|
+
*
|
|
76
|
+
* The TTL above bounds the *hit* path and bounds nothing else. A `kid` the held set does not name is a miss,
|
|
77
|
+
* a miss is a reason to refresh — that is what makes rotation invisible — and `kid` arrives in a header
|
|
78
|
+
* nobody has verified yet. So the miss path was the whole amplifier: `Bearer <jwt with kid: random-uuid>` is
|
|
79
|
+
* exactly as cheap to mint as one naming a published key, and each one bought a round trip to the issuer,
|
|
80
|
+
* 1:1, from an anonymous caller. The cache answered only the forger who bothered to name a real `kid`.
|
|
81
|
+
*
|
|
82
|
+
* A window closes it, and it closes it for every `kid` at once because it is not about the `kid`: at most one
|
|
83
|
+
* ask per window per key endpoint, and a miss inside the window is refused on what we already hold rather
|
|
84
|
+
* than on a fresh look. A random `kid` therefore costs a lookup in memory, and the outbound rate is a
|
|
85
|
+
* constant an attacker cannot move.
|
|
86
|
+
*
|
|
87
|
+
* **A minute, and the number is a rotation delay.** It is how long a genuinely new signing key can be refused
|
|
88
|
+
* before we look again — an issuer publishes the key before the first token names it, and every one of them
|
|
89
|
+
* overlaps rotations by hours, so a minute is inside the overlap and a refusal inside it is a redelivery
|
|
90
|
+
* rather than a loss. Wider trades rotation latency for nothing much: one ask a minute is already too little
|
|
91
|
+
* traffic for any issuer to rate-limit.
|
|
92
|
+
*/
|
|
93
|
+
export declare const OIDC_JWKS_MIN_REFRESH_SECONDS = 60;
|
|
70
94
|
/** Tolerance on `exp`, `nbf` and `iat`. A minute covers clock drift; an hour would cover a replay. */
|
|
71
95
|
export declare const OIDC_CLOCK_SKEW_SECONDS = 60;
|
|
72
96
|
/**
|
|
@@ -151,20 +175,43 @@ export declare const oidcJwksFetch: OidcJwksFetch;
|
|
|
151
175
|
* over KV or the Cache API and shares it across isolates.
|
|
152
176
|
*
|
|
153
177
|
* **Owning the store is not the same as having none.** {@link requireOidcWebhook} builds one per guard when a
|
|
154
|
-
* route gives none, because the guard is the surface anonymous traffic arrives on: with no
|
|
155
|
-
* forged token
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
178
|
+
* route gives none, because the guard is the surface anonymous traffic arrives on: with no store, a 49-byte
|
|
179
|
+
* forged token buys a round trip to the issuer, 1:1, until the issuer rate-limits us and the real deliveries
|
|
180
|
+
* 502 alongside the forgeries. {@link verifyOidcToken} called directly is the other case and does fetch per
|
|
181
|
+
* call — its caller holds one token it has already decided to check, so there is no flood to absorb and no
|
|
182
|
+
* cache to acquire by surprise.
|
|
183
|
+
*
|
|
184
|
+
* **Two facts, not one, and the second is the one that bounds an attacker.** `get`/`set` hold what the issuer
|
|
185
|
+
* published, which answers a `kid` we have seen. {@link claimRefresh} holds *when we last asked*, which is
|
|
186
|
+
* what answers a `kid` we have not — and that is the whole of the miss path, where an unverified header used
|
|
187
|
+
* to buy an outbound request per delivery. Both live in the store rather than in a closure so that a store
|
|
188
|
+
* shared across isolates (KV, the Cache API) shares the bound too: one ask per window for the deployment,
|
|
189
|
+
* not one per isolate.
|
|
159
190
|
*
|
|
160
191
|
* Public keys, so nothing here is what CLAUDE.md's secrets rule governs: the whole point of a JWKS endpoint
|
|
161
|
-
* is that anyone may read it. What the
|
|
192
|
+
* is that anyone may read it. What the store buys is one round trip per TTL on the hit path and one per
|
|
193
|
+
* {@link OIDC_JWKS_MIN_REFRESH_SECONDS} on the miss path, instead of one per delivery on both.
|
|
162
194
|
*/
|
|
163
195
|
export interface JwksCache {
|
|
164
196
|
/** The key set held for `url`, or `undefined` when nothing usable is held. */
|
|
165
197
|
get(url: string): Promise<readonly OidcJwk[] | undefined>;
|
|
166
198
|
/** Hold a freshly fetched key set for `url` for `ttlSeconds`. */
|
|
167
199
|
set(url: string, keys: readonly OidcJwk[], ttlSeconds: number): Promise<void>;
|
|
200
|
+
/**
|
|
201
|
+
* **May the issuer's endpoint be asked for `url` right now?** `true` grants the ask and records it for
|
|
202
|
+
* `seconds`; `false` says somebody already asked inside that window.
|
|
203
|
+
*
|
|
204
|
+
* One call rather than a read and a write, because the question and the claim are the same act: two
|
|
205
|
+
* deliveries that both read *nobody has asked* and then both ask are the flood this exists to stop. An
|
|
206
|
+
* implementation decides and records before it yields — {@link memoryJwksCache} does it synchronously
|
|
207
|
+
* inside the promise, so concurrent deliveries in one isolate produce exactly one ask. A store over a
|
|
208
|
+
* network is best-effort about that, and best-effort is still a bound.
|
|
209
|
+
*
|
|
210
|
+
* It is about the *ask*, never about the answer, which is what keeps a failing endpoint from being a
|
|
211
|
+
* second amplifier: a refresh that ends in `core/upstream_failed` has still spent the window, so a flood
|
|
212
|
+
* arriving while the issuer is down costs one attempt per window rather than one per delivery.
|
|
213
|
+
*/
|
|
214
|
+
claimRefresh(url: string, seconds: number): Promise<boolean>;
|
|
168
215
|
}
|
|
169
216
|
/**
|
|
170
217
|
* A key cache in memory, expiring on a clock the caller owns.
|
|
@@ -205,6 +252,10 @@ export interface OidcWebhookScheme {
|
|
|
205
252
|
/**
|
|
206
253
|
* How long a fetched key set is held. Defaults to {@link OIDC_JWKS_TTL_SECONDS} and must be a finite number
|
|
207
254
|
* of seconds — a `NaN` is an entry that never expires, which pins a key the issuer has rotated out.
|
|
255
|
+
*
|
|
256
|
+
* Keep it above {@link OIDC_JWKS_MIN_REFRESH_SECONDS}. Below it, a set expires while the window that
|
|
257
|
+
* governs re-asking is still spent, and every delivery in the gap is answered `core/upstream_failed`
|
|
258
|
+
* because nothing is held and nothing may be asked. An hour against a minute is not close to that line.
|
|
208
259
|
*/
|
|
209
260
|
jwksTtlSeconds?: number;
|
|
210
261
|
/**
|
|
@@ -236,9 +287,11 @@ export interface OidcWebhookGuardOptions extends OidcWebhookScheme {
|
|
|
236
287
|
* it has not checked yet.
|
|
237
288
|
*
|
|
238
289
|
* @throws {@link WebhookUnverifiedError} (401) when the sender did not prove itself — unreadable token,
|
|
239
|
-
* unknown key, bad signature, wrong issuer or audience,
|
|
290
|
+
* unknown key, a key that cannot verify an `RS256` signature, bad signature, wrong issuer or audience,
|
|
291
|
+
* expired, not yet valid, predicate false.
|
|
240
292
|
* @throws {@link InternalError} (500) when this endpoint is not configured to verify anything.
|
|
241
|
-
* @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read
|
|
293
|
+
* @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read — either because the
|
|
294
|
+
* ask failed, or because it was asked inside {@link OIDC_JWKS_MIN_REFRESH_SECONDS} and nothing is held.
|
|
242
295
|
*/
|
|
243
296
|
export declare function verifyOidcToken(token: string, options: VerifyOidcTokenOptions): Promise<OidcClaims>;
|
|
244
297
|
/**
|
|
@@ -252,21 +305,24 @@ export declare function verifyOidcToken(token: string, options: VerifyOidcTokenO
|
|
|
252
305
|
* be unauthenticated work an anonymous POST could buy on the one route whose purpose is to refuse anonymous
|
|
253
306
|
* callers.
|
|
254
307
|
*
|
|
255
|
-
* ## Why a
|
|
308
|
+
* ## Why a store is not optional here
|
|
256
309
|
*
|
|
257
310
|
* A caller that presents *something* is past that first check, and the next thing the verifier needs is the
|
|
258
311
|
* issuer's key set. So the guard builds a {@link memoryJwksCache} when the route gives none — once, when the
|
|
259
|
-
* middleware is constructed, never per request, which is a
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
312
|
+
* middleware is constructed, never per request, which is a store that has never held anything. A 49-byte
|
|
313
|
+
* token is the cheapest thing on the internet to send, and with no store each one is a round trip to the
|
|
314
|
+
* issuer, 1:1. That is a bandwidth multiplier aimed at somebody else's endpoint, and once the issuer
|
|
315
|
+
* rate-limits us {@link fetchJwks} maps it to `core/upstream_failed` for the genuine deliveries too — an
|
|
316
|
+
* anonymous caller taking the boundary down by asking it politely.
|
|
317
|
+
*
|
|
318
|
+
* **Both halves of the flood, not one.** A token naming a `kid` the issuer really publishes is answered out
|
|
319
|
+
* of what the store holds. A token naming a `kid` nobody has ever published is answered by
|
|
320
|
+
* {@link JwksCache.claimRefresh}: one ask per {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for
|
|
321
|
+
* every `kid` at once, so a `kid` an attacker invents costs a map lookup and no outbound request. The first
|
|
322
|
+
* round of this fix had only the first half, which meant the cache helped exactly against a forger who
|
|
323
|
+
* bothered to copy a real `kid` and not at all against one who did not. A route wanting the bound shared
|
|
324
|
+
* across isolates rather than held per isolate passes its own {@link OidcWebhookScheme.jwksCache} — over KV
|
|
325
|
+
* or the Cache API — and this one is never built.
|
|
270
326
|
*
|
|
271
327
|
* ## Why nothing is published to the request
|
|
272
328
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oidcWebhook.d.ts","sourceRoot":"","sources":["../../src/http/oidcWebhook.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAG7D
|
|
1
|
+
{"version":3,"file":"oidcWebhook.d.ts","sourceRoot":"","sources":["../../src/http/oidcWebhook.ts"],"names":[],"mappings":"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"}
|
package/dist/http/oidcWebhook.js
CHANGED
|
@@ -46,17 +46,20 @@ import { z } from "zod";
|
|
|
46
46
|
* ## What a refusal says
|
|
47
47
|
*
|
|
48
48
|
* `core/webhook_unverified`, 401, one code for every step a sender can fail — unreadable token, unknown key,
|
|
49
|
-
* bad signature, wrong audience, expired, predicate false.
|
|
49
|
+
* a key that cannot verify an `RS256` signature, bad signature, wrong audience, expired, predicate false. A
|
|
50
|
+
* `kid` selects the key, and a `kid` is a value the sender chose, so *every* answer the key leads to is the
|
|
51
|
+
* sender's. The step goes in `detail`, which the HTTP codec
|
|
50
52
|
* strips, so an operator reading a log learns which check refused it and the sender learns only that
|
|
51
53
|
* something did. Values that decide something are named there (`kid`, `iss`, `aud`) because a misconfigured
|
|
52
54
|
* audience is the realistic failure and an operator cannot fix what the log will not name — truncated by
|
|
53
55
|
* {@link snippet}, and never the signature and never the whole token.
|
|
54
56
|
*
|
|
55
|
-
* Two failures are deliberately **not** the sender's
|
|
56
|
-
* endpoint, or a clock that is not a clock is a
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* read *our* logs and the thing that broke
|
|
57
|
+
* Two failures are deliberately **not** the sender's, and the test for both is whether the sender could have
|
|
58
|
+
* chosen them. An endpoint with no issuers, no audience, no key endpoint, or a clock that is not a clock is a
|
|
59
|
+
* configuration fault and takes `core/internal` — reporting it as an unverified webhook sends an operator
|
|
60
|
+
* hunting a forger who is not there. And a key endpoint that cannot be reached or answers nonsense is
|
|
61
|
+
* `core/upstream_failed` (502), because a 500 tells an operator to read *our* logs and the thing that broke
|
|
62
|
+
* is the issuer's; no `kid` decides whether that happens. Both still refuse the delivery: fail closed is
|
|
60
63
|
* about what happens to the request, not about whose code goes on it.
|
|
61
64
|
*
|
|
62
65
|
* ## What this proves, and what it does not
|
|
@@ -65,8 +68,29 @@ import { z } from "zod";
|
|
|
65
68
|
* never that it is *new*. A captured token replays until it expires. A handler that grants, charges, or
|
|
66
69
|
* deletes needs its own uniqueness key.
|
|
67
70
|
*/
|
|
68
|
-
/** How long a fetched key set is reused. Issuers rotate slowly,
|
|
71
|
+
/** How long a fetched key set is reused. Issuers rotate slowly, so an hour of it is one round trip an hour. */
|
|
69
72
|
const OIDC_JWKS_TTL_SECONDS = 3600;
|
|
73
|
+
/**
|
|
74
|
+
* **The most often the issuer's key endpoint is asked, whatever a token's `kid` claims.**
|
|
75
|
+
*
|
|
76
|
+
* The TTL above bounds the *hit* path and bounds nothing else. A `kid` the held set does not name is a miss,
|
|
77
|
+
* a miss is a reason to refresh — that is what makes rotation invisible — and `kid` arrives in a header
|
|
78
|
+
* nobody has verified yet. So the miss path was the whole amplifier: `Bearer <jwt with kid: random-uuid>` is
|
|
79
|
+
* exactly as cheap to mint as one naming a published key, and each one bought a round trip to the issuer,
|
|
80
|
+
* 1:1, from an anonymous caller. The cache answered only the forger who bothered to name a real `kid`.
|
|
81
|
+
*
|
|
82
|
+
* A window closes it, and it closes it for every `kid` at once because it is not about the `kid`: at most one
|
|
83
|
+
* ask per window per key endpoint, and a miss inside the window is refused on what we already hold rather
|
|
84
|
+
* than on a fresh look. A random `kid` therefore costs a lookup in memory, and the outbound rate is a
|
|
85
|
+
* constant an attacker cannot move.
|
|
86
|
+
*
|
|
87
|
+
* **A minute, and the number is a rotation delay.** It is how long a genuinely new signing key can be refused
|
|
88
|
+
* before we look again — an issuer publishes the key before the first token names it, and every one of them
|
|
89
|
+
* overlaps rotations by hours, so a minute is inside the overlap and a refusal inside it is a redelivery
|
|
90
|
+
* rather than a loss. Wider trades rotation latency for nothing much: one ask a minute is already too little
|
|
91
|
+
* traffic for any issuer to rate-limit.
|
|
92
|
+
*/
|
|
93
|
+
const OIDC_JWKS_MIN_REFRESH_SECONDS = 60;
|
|
70
94
|
/** Tolerance on `exp`, `nbf` and `iat`. A minute covers clock drift; an hour would cover a replay. */
|
|
71
95
|
const OIDC_CLOCK_SKEW_SECONDS = 60;
|
|
72
96
|
/**
|
|
@@ -142,6 +166,8 @@ const oidcJwksFetch = (url) => fetch(url);
|
|
|
142
166
|
function memoryJwksCache(options = {}) {
|
|
143
167
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
144
168
|
const held = /* @__PURE__ */ new Map();
|
|
169
|
+
/** When the endpoint behind each url may be asked again. The miss path's whole state. */
|
|
170
|
+
const askedUntil = /* @__PURE__ */ new Map();
|
|
145
171
|
return {
|
|
146
172
|
get: async (url) => {
|
|
147
173
|
const entry = held.get(url);
|
|
@@ -157,6 +183,13 @@ function memoryJwksCache(options = {}) {
|
|
|
157
183
|
keys,
|
|
158
184
|
expiresAt: now().getTime() + ttlSeconds * 1e3
|
|
159
185
|
});
|
|
186
|
+
},
|
|
187
|
+
claimRefresh: async (url, seconds) => {
|
|
188
|
+
const at = now().getTime();
|
|
189
|
+
const until = askedUntil.get(url);
|
|
190
|
+
if (until !== void 0 && until > at) return false;
|
|
191
|
+
askedUntil.set(url, at + seconds * 1e3);
|
|
192
|
+
return true;
|
|
160
193
|
}
|
|
161
194
|
};
|
|
162
195
|
}
|
|
@@ -169,9 +202,11 @@ function memoryJwksCache(options = {}) {
|
|
|
169
202
|
* it has not checked yet.
|
|
170
203
|
*
|
|
171
204
|
* @throws {@link WebhookUnverifiedError} (401) when the sender did not prove itself — unreadable token,
|
|
172
|
-
* unknown key, bad signature, wrong issuer or audience,
|
|
205
|
+
* unknown key, a key that cannot verify an `RS256` signature, bad signature, wrong issuer or audience,
|
|
206
|
+
* expired, not yet valid, predicate false.
|
|
173
207
|
* @throws {@link InternalError} (500) when this endpoint is not configured to verify anything.
|
|
174
|
-
* @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read
|
|
208
|
+
* @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read — either because the
|
|
209
|
+
* ask failed, or because it was asked inside {@link OIDC_JWKS_MIN_REFRESH_SECONDS} and nothing is held.
|
|
175
210
|
*/
|
|
176
211
|
async function verifyOidcToken(token, options) {
|
|
177
212
|
const issuers = options.issuers.filter((issuer) => issuer.length > 0);
|
|
@@ -209,21 +244,24 @@ async function verifyOidcToken(token, options) {
|
|
|
209
244
|
* be unauthenticated work an anonymous POST could buy on the one route whose purpose is to refuse anonymous
|
|
210
245
|
* callers.
|
|
211
246
|
*
|
|
212
|
-
* ## Why a
|
|
247
|
+
* ## Why a store is not optional here
|
|
213
248
|
*
|
|
214
249
|
* A caller that presents *something* is past that first check, and the next thing the verifier needs is the
|
|
215
250
|
* issuer's key set. So the guard builds a {@link memoryJwksCache} when the route gives none — once, when the
|
|
216
|
-
* middleware is constructed, never per request, which is a
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
251
|
+
* middleware is constructed, never per request, which is a store that has never held anything. A 49-byte
|
|
252
|
+
* token is the cheapest thing on the internet to send, and with no store each one is a round trip to the
|
|
253
|
+
* issuer, 1:1. That is a bandwidth multiplier aimed at somebody else's endpoint, and once the issuer
|
|
254
|
+
* rate-limits us {@link fetchJwks} maps it to `core/upstream_failed` for the genuine deliveries too — an
|
|
255
|
+
* anonymous caller taking the boundary down by asking it politely.
|
|
256
|
+
*
|
|
257
|
+
* **Both halves of the flood, not one.** A token naming a `kid` the issuer really publishes is answered out
|
|
258
|
+
* of what the store holds. A token naming a `kid` nobody has ever published is answered by
|
|
259
|
+
* {@link JwksCache.claimRefresh}: one ask per {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for
|
|
260
|
+
* every `kid` at once, so a `kid` an attacker invents costs a map lookup and no outbound request. The first
|
|
261
|
+
* round of this fix had only the first half, which meant the cache helped exactly against a forger who
|
|
262
|
+
* bothered to copy a real `kid` and not at all against one who did not. A route wanting the bound shared
|
|
263
|
+
* across isolates rather than held per isolate passes its own {@link OidcWebhookScheme.jwksCache} — over KV
|
|
264
|
+
* or the Cache API — and this one is never built.
|
|
227
265
|
*
|
|
228
266
|
* ## Why nothing is published to the request
|
|
229
267
|
*
|
|
@@ -252,20 +290,44 @@ function requireOidcWebhook(options) {
|
|
|
252
290
|
};
|
|
253
291
|
}
|
|
254
292
|
/**
|
|
255
|
-
* The key a token's `kid` names, from the
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
293
|
+
* The key a token's `kid` names, from the store when it holds it and from the issuer otherwise.
|
|
294
|
+
*
|
|
295
|
+
* ## The miss path is the one an anonymous caller reaches
|
|
296
|
+
*
|
|
297
|
+
* A `kid` the store does not hold is a reason to refresh, and it has to be: a new signing key appears in a
|
|
298
|
+
* token before anything tells us to look for it, so waiting out a TTL would be an outage. But `kid` is read
|
|
299
|
+
* out of a header nobody has verified yet, and the two things it can mean — *the issuer rotated* and *I made
|
|
300
|
+
* this up* — are indistinguishable until after the fetch it costs. That is the whole amplifier, and it is why
|
|
301
|
+
* a store keyed only on what the issuer published answered the wrong half of it: the forger who names a
|
|
302
|
+
* published `kid` was collapsed onto one fetch, and the one who names `crypto.randomUUID()` bought a round
|
|
303
|
+
* trip per delivery, from an endpoint whose purpose is refusing anonymous callers.
|
|
304
|
+
*
|
|
305
|
+
* So the ask is claimed before it is made — {@link JwksCache.claimRefresh}, one per
|
|
306
|
+
* {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for every `kid` together rather than per `kid`.
|
|
307
|
+
* Per `kid` would be no bound at all: an attacker has as many of those as they care to type.
|
|
308
|
+
*
|
|
309
|
+
* ## What a miss inside the window answers, and why it is two different codes
|
|
310
|
+
*
|
|
311
|
+
* Whatever we hold is what the issuer published a moment ago, so a `kid` outside it is a `kid` the issuer
|
|
312
|
+
* does not publish — the sender's failure, 401, audited, and not worth retrying. **Unless we hold nothing**,
|
|
313
|
+
* which means the last ask inside this window failed or has not come back: then the honest answer is that we
|
|
314
|
+
* could not look, and that is the issuer's 502. Neither answer costs an outbound request, which is the point.
|
|
315
|
+
*
|
|
316
|
+
* With no store at all there is nothing to claim and nothing to hold, so every call fetches. That is
|
|
317
|
+
* {@link verifyOidcToken}'s direct-call shape — one caller, one token it already decided to check — and
|
|
318
|
+
* never the guard's, which builds a store precisely so this paragraph does not describe it.
|
|
263
319
|
*/
|
|
264
320
|
async function resolveKey(kid, options) {
|
|
265
|
-
const
|
|
321
|
+
const cache = options.jwksCache;
|
|
322
|
+
const held = await cache?.get(options.jwksUrl);
|
|
323
|
+
const cached = held?.find((key) => key.kid === kid);
|
|
266
324
|
if (cached) return cached;
|
|
325
|
+
if (cache !== void 0 && !await cache.claimRefresh(options.jwksUrl, 60)) {
|
|
326
|
+
if (held === void 0 || held.length === 0) throw upstream(`The OIDC key endpoint ${options.jwksUrl} was asked within the last 60 seconds and nothing is held, so the token's kid could not be looked up.`);
|
|
327
|
+
throw unverified(`no key the issuer publishes matches the token's kid ${snippet(kid)}, and the published set was read within the last 60 seconds.`);
|
|
328
|
+
}
|
|
267
329
|
const refreshed = await fetchJwks(options);
|
|
268
|
-
await
|
|
330
|
+
await cache?.set(options.jwksUrl, refreshed, options.jwksTtlSeconds ?? 3600);
|
|
269
331
|
const found = refreshed.find((key) => key.kid === kid);
|
|
270
332
|
if (!found) throw unverified(`no key the issuer publishes matches the token's kid ${snippet(kid)}.`);
|
|
271
333
|
return found;
|
|
@@ -297,16 +359,30 @@ async function fetchJwks(options) {
|
|
|
297
359
|
if (!parsed.success) throw upstream(`The OIDC key endpoint ${options.jwksUrl} answered an unexpected shape — ${issues(parsed.error)}.`);
|
|
298
360
|
return parsed.data.keys;
|
|
299
361
|
}
|
|
300
|
-
/**
|
|
362
|
+
/**
|
|
363
|
+
* Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto.
|
|
364
|
+
*
|
|
365
|
+
* **Both refusals are the sender's, 401, and the reason is who chose the key.** A published key that cannot
|
|
366
|
+
* verify an `RS256` signature looks like the issuer's fact, and as a fact about the key set it is — but the
|
|
367
|
+
* key reached here because a `kid` in an unverified header selected it, so as an *answer* it is attacker-
|
|
368
|
+
* chosen. A 502 hands that caller three things it must not have: a code the webhook guard passes through
|
|
369
|
+
* untouched, so the refusal never reaches the audit trail that exists to show a rail being probed; a status
|
|
370
|
+
* a sender is invited to retry, which for Pub/Sub means forever; and a lever for picking which of those two
|
|
371
|
+
* an endpoint answers. The narrower reading is also the true one — a token naming a key that cannot check its
|
|
372
|
+
* own algorithm is a token nothing can verify, which is what `core/webhook_unverified` says.
|
|
373
|
+
*
|
|
374
|
+
* The issuer's own failures stay the issuer's: {@link fetchJwks} is 502 for every one of them, and no `kid`
|
|
375
|
+
* decides whether they happen.
|
|
376
|
+
*/
|
|
301
377
|
async function importKey(jwk) {
|
|
302
|
-
if (jwk.kty !== "RSA") throw
|
|
378
|
+
if (jwk.kty !== "RSA") throw unverified(`the token's kid ${snippet(jwk.kid)} names a ${snippet(jwk.kty)} key, and only RSA keys sign an OIDC token.`);
|
|
303
379
|
try {
|
|
304
380
|
return await crypto.subtle.importKey("jwk", {
|
|
305
381
|
...jwk,
|
|
306
382
|
alg: "RS256"
|
|
307
383
|
}, RS256, false, ["verify"]);
|
|
308
384
|
} catch (cause) {
|
|
309
|
-
throw
|
|
385
|
+
throw unverified(`the key the token's kid ${snippet(jwk.kid)} names could not be imported.`, cause);
|
|
310
386
|
}
|
|
311
387
|
}
|
|
312
388
|
/**
|
|
@@ -411,4 +487,4 @@ function snippet(value) {
|
|
|
411
487
|
return JSON.stringify(trimmed);
|
|
412
488
|
}
|
|
413
489
|
//#endregion
|
|
414
|
-
export { OIDC_CLOCK_SKEW_SECONDS, OIDC_JWKS_TTL_SECONDS, OIDC_MAX_CLOCK_SKEW_SECONDS, OidcClaims, OidcJwk, OidcJwks, memoryJwksCache, oidcJwksFetch, requireOidcWebhook, verifyOidcToken };
|
|
490
|
+
export { OIDC_CLOCK_SKEW_SECONDS, OIDC_JWKS_MIN_REFRESH_SECONDS, OIDC_JWKS_TTL_SECONDS, OIDC_MAX_CLOCK_SKEW_SECONDS, OidcClaims, OidcJwk, OidcJwks, memoryJwksCache, oidcJwksFetch, requireOidcWebhook, verifyOidcToken };
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
/** This package's npm name — the join key against a release feed. */
|
|
4
4
|
export declare const PACKAGE_NAME = "@pithy-sh/core";
|
|
5
5
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
6
|
-
export declare const PACKAGE_VERSION = "0.
|
|
6
|
+
export declare const PACKAGE_VERSION = "0.4.1";
|
|
7
7
|
//# sourceMappingURL=version.generated.d.ts.map
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
/** This package's npm name — the join key against a release feed. */
|
|
5
5
|
const PACKAGE_NAME = "@pithy-sh/core";
|
|
6
6
|
/** This package's version, stamped from its own package.json at generation time. */
|
|
7
|
-
const PACKAGE_VERSION = "0.
|
|
7
|
+
const PACKAGE_VERSION = "0.4.1";
|
|
8
8
|
//#endregion
|
|
9
9
|
export { PACKAGE_NAME, PACKAGE_VERSION };
|
package/package.json
CHANGED
|
@@ -155,6 +155,16 @@ export interface SecretRegistryEntrySeam {
|
|
|
155
155
|
* which it can do without executing the package.
|
|
156
156
|
*/
|
|
157
157
|
readonly devValue?: string;
|
|
158
|
+
/**
|
|
159
|
+
* The Worker binding this value exists to reach, or absent when it reaches none — the constant half
|
|
160
|
+
* of *can this composition use this secret* (#541).
|
|
161
|
+
*
|
|
162
|
+
* Read here and never interpreted, exactly like {@link devValue}: the CLI joins it against a Worker's
|
|
163
|
+
* `declinedBindings` so a credential whose bucket the project declined stops being listed as
|
|
164
|
+
* outstanding work. `@pithy-sh/secrets` owns the concrete field; core needs it on the seam so a
|
|
165
|
+
* reporting surface can read a capability's declared slice off this contract.
|
|
166
|
+
*/
|
|
167
|
+
readonly binding?: string;
|
|
158
168
|
}
|
|
159
169
|
|
|
160
170
|
/** A capability's secret-registry slice: secret name → {@link SecretRegistryEntrySeam}. */
|
|
@@ -188,6 +198,35 @@ export type SecretRegistrySeam = Record<string, SecretRegistryEntrySeam>;
|
|
|
188
198
|
*/
|
|
189
199
|
export type SecretBranchSeam = Record<string, readonly string[]>;
|
|
190
200
|
|
|
201
|
+
/**
|
|
202
|
+
* **Which of this capability's secrets this Worker's configuration cannot reach** — secret name → the
|
|
203
|
+
* reason, in an operator's words.
|
|
204
|
+
*
|
|
205
|
+
* The neighbor of {@link SecretBranchSeam}, one level up. That one says which *blocks* of a bundle
|
|
206
|
+
* apply when the bundle itself does; this says that a whole secret does not. `@pithy-sh/auth` declares
|
|
207
|
+
* one credential per OAuth provider, so a project running Google and GitHub has two secrets that no
|
|
208
|
+
* code path will ever read — and until #541 `pithy doctor` listed both as outstanding work under
|
|
209
|
+
* *"fine to leave until you need it"*, which is not true of a credential the configuration has refused.
|
|
210
|
+
*
|
|
211
|
+
* **Declared, never inferred**, for {@link SecretBranchSeam}'s reason: `auth-apple-credentials` and
|
|
212
|
+
* `providers.apple` are spelled alike today and nothing makes a secret name and a config key the same
|
|
213
|
+
* thing. The capability holds both halves at construction; a CLI matching them by substring would be
|
|
214
|
+
* right by luck.
|
|
215
|
+
*
|
|
216
|
+
* **Per composition, which is why it is here and not on the registry entry.** A registry entry is plain
|
|
217
|
+
* data shared by every Worker that declares the name; whether a provider is enabled is one Worker's
|
|
218
|
+
* configuration. The constant half of the same question — *which binding does this credential exist to
|
|
219
|
+
* reach* — is on the entry, as `binding`, because it does not change between compositions.
|
|
220
|
+
*
|
|
221
|
+
* **The reason reaches a terminal, so it is a sentence and not a code.** Name the configuration that
|
|
222
|
+
* decided it, the way an operator would have to edit it: `providers.apple is not enabled in
|
|
223
|
+
* pithy.config.ts`. It answers *what would I have to do to turn this on* as well as *why is this here*.
|
|
224
|
+
*
|
|
225
|
+
* Declaring nothing is the ordinary case and means every declared secret applies. It is descriptive
|
|
226
|
+
* only: nothing in the runtime reads it, and no command acts on it beyond reporting.
|
|
227
|
+
*/
|
|
228
|
+
export type InapplicableSecretSeam = Record<string, string>;
|
|
229
|
+
|
|
191
230
|
/**
|
|
192
231
|
* The structural seam for one capability's token-profile slice — profile name → the scoped CF API
|
|
193
232
|
* token that capability's code needs. Declared next to the code that uses it (alongside
|
|
@@ -332,6 +371,12 @@ export interface Capability<
|
|
|
332
371
|
* no `json` secret at all, declares nothing.
|
|
333
372
|
*/
|
|
334
373
|
secretBranches?: SecretBranchSeam;
|
|
374
|
+
/**
|
|
375
|
+
* Which of this capability's secrets this Worker's configuration cannot reach, and why — see
|
|
376
|
+
* {@link InapplicableSecretSeam}. Additive and optional: a capability whose secrets all apply
|
|
377
|
+
* whenever it is composed declares nothing, which is the ordinary case.
|
|
378
|
+
*/
|
|
379
|
+
inapplicableSecrets?: InapplicableSecretSeam;
|
|
335
380
|
/**
|
|
336
381
|
* The scoped CF API tokens this capability's code needs, as a token-profile slice (profile name →
|
|
337
382
|
* {@link TokenProfileSeam}). Additive and optional, and declared next to {@link Capability.secretRegistry}
|
package/src/http/oidcWebhook.ts
CHANGED
|
@@ -49,17 +49,20 @@ import { InternalError, UpstreamError, WebhookUnverifiedError } from "../error/p
|
|
|
49
49
|
* ## What a refusal says
|
|
50
50
|
*
|
|
51
51
|
* `core/webhook_unverified`, 401, one code for every step a sender can fail — unreadable token, unknown key,
|
|
52
|
-
* bad signature, wrong audience, expired, predicate false.
|
|
52
|
+
* a key that cannot verify an `RS256` signature, bad signature, wrong audience, expired, predicate false. A
|
|
53
|
+
* `kid` selects the key, and a `kid` is a value the sender chose, so *every* answer the key leads to is the
|
|
54
|
+
* sender's. The step goes in `detail`, which the HTTP codec
|
|
53
55
|
* strips, so an operator reading a log learns which check refused it and the sender learns only that
|
|
54
56
|
* something did. Values that decide something are named there (`kid`, `iss`, `aud`) because a misconfigured
|
|
55
57
|
* audience is the realistic failure and an operator cannot fix what the log will not name — truncated by
|
|
56
58
|
* {@link snippet}, and never the signature and never the whole token.
|
|
57
59
|
*
|
|
58
|
-
* Two failures are deliberately **not** the sender's
|
|
59
|
-
* endpoint, or a clock that is not a clock is a
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* read *our* logs and the thing that broke
|
|
60
|
+
* Two failures are deliberately **not** the sender's, and the test for both is whether the sender could have
|
|
61
|
+
* chosen them. An endpoint with no issuers, no audience, no key endpoint, or a clock that is not a clock is a
|
|
62
|
+
* configuration fault and takes `core/internal` — reporting it as an unverified webhook sends an operator
|
|
63
|
+
* hunting a forger who is not there. And a key endpoint that cannot be reached or answers nonsense is
|
|
64
|
+
* `core/upstream_failed` (502), because a 500 tells an operator to read *our* logs and the thing that broke
|
|
65
|
+
* is the issuer's; no `kid` decides whether that happens. Both still refuse the delivery: fail closed is
|
|
63
66
|
* about what happens to the request, not about whose code goes on it.
|
|
64
67
|
*
|
|
65
68
|
* ## What this proves, and what it does not
|
|
@@ -69,9 +72,31 @@ import { InternalError, UpstreamError, WebhookUnverifiedError } from "../error/p
|
|
|
69
72
|
* deletes needs its own uniqueness key.
|
|
70
73
|
*/
|
|
71
74
|
|
|
72
|
-
/** How long a fetched key set is reused. Issuers rotate slowly,
|
|
75
|
+
/** How long a fetched key set is reused. Issuers rotate slowly, so an hour of it is one round trip an hour. */
|
|
73
76
|
export const OIDC_JWKS_TTL_SECONDS = 3600;
|
|
74
77
|
|
|
78
|
+
/**
|
|
79
|
+
* **The most often the issuer's key endpoint is asked, whatever a token's `kid` claims.**
|
|
80
|
+
*
|
|
81
|
+
* The TTL above bounds the *hit* path and bounds nothing else. A `kid` the held set does not name is a miss,
|
|
82
|
+
* a miss is a reason to refresh — that is what makes rotation invisible — and `kid` arrives in a header
|
|
83
|
+
* nobody has verified yet. So the miss path was the whole amplifier: `Bearer <jwt with kid: random-uuid>` is
|
|
84
|
+
* exactly as cheap to mint as one naming a published key, and each one bought a round trip to the issuer,
|
|
85
|
+
* 1:1, from an anonymous caller. The cache answered only the forger who bothered to name a real `kid`.
|
|
86
|
+
*
|
|
87
|
+
* A window closes it, and it closes it for every `kid` at once because it is not about the `kid`: at most one
|
|
88
|
+
* ask per window per key endpoint, and a miss inside the window is refused on what we already hold rather
|
|
89
|
+
* than on a fresh look. A random `kid` therefore costs a lookup in memory, and the outbound rate is a
|
|
90
|
+
* constant an attacker cannot move.
|
|
91
|
+
*
|
|
92
|
+
* **A minute, and the number is a rotation delay.** It is how long a genuinely new signing key can be refused
|
|
93
|
+
* before we look again — an issuer publishes the key before the first token names it, and every one of them
|
|
94
|
+
* overlaps rotations by hours, so a minute is inside the overlap and a refusal inside it is a redelivery
|
|
95
|
+
* rather than a loss. Wider trades rotation latency for nothing much: one ask a minute is already too little
|
|
96
|
+
* traffic for any issuer to rate-limit.
|
|
97
|
+
*/
|
|
98
|
+
export const OIDC_JWKS_MIN_REFRESH_SECONDS = 60;
|
|
99
|
+
|
|
75
100
|
/** Tolerance on `exp`, `nbf` and `iat`. A minute covers clock drift; an hour would cover a replay. */
|
|
76
101
|
export const OIDC_CLOCK_SKEW_SECONDS = 60;
|
|
77
102
|
|
|
@@ -210,20 +235,43 @@ export const oidcJwksFetch: OidcJwksFetch = (url) => fetch(url) as unknown as Pr
|
|
|
210
235
|
* over KV or the Cache API and shares it across isolates.
|
|
211
236
|
*
|
|
212
237
|
* **Owning the store is not the same as having none.** {@link requireOidcWebhook} builds one per guard when a
|
|
213
|
-
* route gives none, because the guard is the surface anonymous traffic arrives on: with no
|
|
214
|
-
* forged token
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
238
|
+
* route gives none, because the guard is the surface anonymous traffic arrives on: with no store, a 49-byte
|
|
239
|
+
* forged token buys a round trip to the issuer, 1:1, until the issuer rate-limits us and the real deliveries
|
|
240
|
+
* 502 alongside the forgeries. {@link verifyOidcToken} called directly is the other case and does fetch per
|
|
241
|
+
* call — its caller holds one token it has already decided to check, so there is no flood to absorb and no
|
|
242
|
+
* cache to acquire by surprise.
|
|
243
|
+
*
|
|
244
|
+
* **Two facts, not one, and the second is the one that bounds an attacker.** `get`/`set` hold what the issuer
|
|
245
|
+
* published, which answers a `kid` we have seen. {@link claimRefresh} holds *when we last asked*, which is
|
|
246
|
+
* what answers a `kid` we have not — and that is the whole of the miss path, where an unverified header used
|
|
247
|
+
* to buy an outbound request per delivery. Both live in the store rather than in a closure so that a store
|
|
248
|
+
* shared across isolates (KV, the Cache API) shares the bound too: one ask per window for the deployment,
|
|
249
|
+
* not one per isolate.
|
|
218
250
|
*
|
|
219
251
|
* Public keys, so nothing here is what CLAUDE.md's secrets rule governs: the whole point of a JWKS endpoint
|
|
220
|
-
* is that anyone may read it. What the
|
|
252
|
+
* is that anyone may read it. What the store buys is one round trip per TTL on the hit path and one per
|
|
253
|
+
* {@link OIDC_JWKS_MIN_REFRESH_SECONDS} on the miss path, instead of one per delivery on both.
|
|
221
254
|
*/
|
|
222
255
|
export interface JwksCache {
|
|
223
256
|
/** The key set held for `url`, or `undefined` when nothing usable is held. */
|
|
224
257
|
get(url: string): Promise<readonly OidcJwk[] | undefined>;
|
|
225
258
|
/** Hold a freshly fetched key set for `url` for `ttlSeconds`. */
|
|
226
259
|
set(url: string, keys: readonly OidcJwk[], ttlSeconds: number): Promise<void>;
|
|
260
|
+
/**
|
|
261
|
+
* **May the issuer's endpoint be asked for `url` right now?** `true` grants the ask and records it for
|
|
262
|
+
* `seconds`; `false` says somebody already asked inside that window.
|
|
263
|
+
*
|
|
264
|
+
* One call rather than a read and a write, because the question and the claim are the same act: two
|
|
265
|
+
* deliveries that both read *nobody has asked* and then both ask are the flood this exists to stop. An
|
|
266
|
+
* implementation decides and records before it yields — {@link memoryJwksCache} does it synchronously
|
|
267
|
+
* inside the promise, so concurrent deliveries in one isolate produce exactly one ask. A store over a
|
|
268
|
+
* network is best-effort about that, and best-effort is still a bound.
|
|
269
|
+
*
|
|
270
|
+
* It is about the *ask*, never about the answer, which is what keeps a failing endpoint from being a
|
|
271
|
+
* second amplifier: a refresh that ends in `core/upstream_failed` has still spent the window, so a flood
|
|
272
|
+
* arriving while the issuer is down costs one attempt per window rather than one per delivery.
|
|
273
|
+
*/
|
|
274
|
+
claimRefresh(url: string, seconds: number): Promise<boolean>;
|
|
227
275
|
}
|
|
228
276
|
|
|
229
277
|
/**
|
|
@@ -236,6 +284,8 @@ export interface JwksCache {
|
|
|
236
284
|
export function memoryJwksCache(options: { now?: () => Date } = {}): JwksCache {
|
|
237
285
|
const now = options.now ?? (() => new Date());
|
|
238
286
|
const held = new Map<string, { keys: readonly OidcJwk[]; expiresAt: number }>();
|
|
287
|
+
/** When the endpoint behind each url may be asked again. The miss path's whole state. */
|
|
288
|
+
const askedUntil = new Map<string, number>();
|
|
239
289
|
return {
|
|
240
290
|
get: async (url) => {
|
|
241
291
|
const entry = held.get(url);
|
|
@@ -249,6 +299,16 @@ export function memoryJwksCache(options: { now?: () => Date } = {}): JwksCache {
|
|
|
249
299
|
set: async (url, keys, ttlSeconds) => {
|
|
250
300
|
held.set(url, { keys, expiresAt: now().getTime() + ttlSeconds * 1000 });
|
|
251
301
|
},
|
|
302
|
+
// Decided and recorded with no `await` between the read and the write, which is what makes this a claim
|
|
303
|
+
// rather than a check: an isolate handling a thousand concurrent forgeries runs this body a thousand
|
|
304
|
+
// times and grants it once. The async signature is the seam's, for the stores that do reach a network.
|
|
305
|
+
claimRefresh: async (url, seconds) => {
|
|
306
|
+
const at = now().getTime();
|
|
307
|
+
const until = askedUntil.get(url);
|
|
308
|
+
if (until !== undefined && until > at) return false;
|
|
309
|
+
askedUntil.set(url, at + seconds * 1000);
|
|
310
|
+
return true;
|
|
311
|
+
},
|
|
252
312
|
};
|
|
253
313
|
}
|
|
254
314
|
|
|
@@ -281,6 +341,10 @@ export interface OidcWebhookScheme {
|
|
|
281
341
|
/**
|
|
282
342
|
* How long a fetched key set is held. Defaults to {@link OIDC_JWKS_TTL_SECONDS} and must be a finite number
|
|
283
343
|
* of seconds — a `NaN` is an entry that never expires, which pins a key the issuer has rotated out.
|
|
344
|
+
*
|
|
345
|
+
* Keep it above {@link OIDC_JWKS_MIN_REFRESH_SECONDS}. Below it, a set expires while the window that
|
|
346
|
+
* governs re-asking is still spent, and every delivery in the gap is answered `core/upstream_failed`
|
|
347
|
+
* because nothing is held and nothing may be asked. An hour against a minute is not close to that line.
|
|
284
348
|
*/
|
|
285
349
|
jwksTtlSeconds?: number;
|
|
286
350
|
/**
|
|
@@ -315,9 +379,11 @@ export interface OidcWebhookGuardOptions extends OidcWebhookScheme {
|
|
|
315
379
|
* it has not checked yet.
|
|
316
380
|
*
|
|
317
381
|
* @throws {@link WebhookUnverifiedError} (401) when the sender did not prove itself — unreadable token,
|
|
318
|
-
* unknown key, bad signature, wrong issuer or audience,
|
|
382
|
+
* unknown key, a key that cannot verify an `RS256` signature, bad signature, wrong issuer or audience,
|
|
383
|
+
* expired, not yet valid, predicate false.
|
|
319
384
|
* @throws {@link InternalError} (500) when this endpoint is not configured to verify anything.
|
|
320
|
-
* @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read
|
|
385
|
+
* @throws {@link UpstreamError} (502) when the issuer's key endpoint could not be read — either because the
|
|
386
|
+
* ask failed, or because it was asked inside {@link OIDC_JWKS_MIN_REFRESH_SECONDS} and nothing is held.
|
|
321
387
|
*/
|
|
322
388
|
export async function verifyOidcToken(token: string, options: VerifyOidcTokenOptions): Promise<OidcClaims> {
|
|
323
389
|
// Ours before theirs. An endpoint with nothing to check against refuses every delivery, and calling that an
|
|
@@ -435,21 +501,24 @@ export async function verifyOidcToken(token: string, options: VerifyOidcTokenOpt
|
|
|
435
501
|
* be unauthenticated work an anonymous POST could buy on the one route whose purpose is to refuse anonymous
|
|
436
502
|
* callers.
|
|
437
503
|
*
|
|
438
|
-
* ## Why a
|
|
504
|
+
* ## Why a store is not optional here
|
|
439
505
|
*
|
|
440
506
|
* A caller that presents *something* is past that first check, and the next thing the verifier needs is the
|
|
441
507
|
* issuer's key set. So the guard builds a {@link memoryJwksCache} when the route gives none — once, when the
|
|
442
|
-
* middleware is constructed, never per request, which is a
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
508
|
+
* middleware is constructed, never per request, which is a store that has never held anything. A 49-byte
|
|
509
|
+
* token is the cheapest thing on the internet to send, and with no store each one is a round trip to the
|
|
510
|
+
* issuer, 1:1. That is a bandwidth multiplier aimed at somebody else's endpoint, and once the issuer
|
|
511
|
+
* rate-limits us {@link fetchJwks} maps it to `core/upstream_failed` for the genuine deliveries too — an
|
|
512
|
+
* anonymous caller taking the boundary down by asking it politely.
|
|
513
|
+
*
|
|
514
|
+
* **Both halves of the flood, not one.** A token naming a `kid` the issuer really publishes is answered out
|
|
515
|
+
* of what the store holds. A token naming a `kid` nobody has ever published is answered by
|
|
516
|
+
* {@link JwksCache.claimRefresh}: one ask per {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for
|
|
517
|
+
* every `kid` at once, so a `kid` an attacker invents costs a map lookup and no outbound request. The first
|
|
518
|
+
* round of this fix had only the first half, which meant the cache helped exactly against a forger who
|
|
519
|
+
* bothered to copy a real `kid` and not at all against one who did not. A route wanting the bound shared
|
|
520
|
+
* across isolates rather than held per isolate passes its own {@link OidcWebhookScheme.jwksCache} — over KV
|
|
521
|
+
* or the Cache API — and this one is never built.
|
|
453
522
|
*
|
|
454
523
|
* ## Why nothing is published to the request
|
|
455
524
|
*
|
|
@@ -482,22 +551,52 @@ export function requireOidcWebhook(options: OidcWebhookGuardOptions): Middleware
|
|
|
482
551
|
}
|
|
483
552
|
|
|
484
553
|
/**
|
|
485
|
-
* The key a token's `kid` names, from the
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
554
|
+
* The key a token's `kid` names, from the store when it holds it and from the issuer otherwise.
|
|
555
|
+
*
|
|
556
|
+
* ## The miss path is the one an anonymous caller reaches
|
|
557
|
+
*
|
|
558
|
+
* A `kid` the store does not hold is a reason to refresh, and it has to be: a new signing key appears in a
|
|
559
|
+
* token before anything tells us to look for it, so waiting out a TTL would be an outage. But `kid` is read
|
|
560
|
+
* out of a header nobody has verified yet, and the two things it can mean — *the issuer rotated* and *I made
|
|
561
|
+
* this up* — are indistinguishable until after the fetch it costs. That is the whole amplifier, and it is why
|
|
562
|
+
* a store keyed only on what the issuer published answered the wrong half of it: the forger who names a
|
|
563
|
+
* published `kid` was collapsed onto one fetch, and the one who names `crypto.randomUUID()` bought a round
|
|
564
|
+
* trip per delivery, from an endpoint whose purpose is refusing anonymous callers.
|
|
565
|
+
*
|
|
566
|
+
* So the ask is claimed before it is made — {@link JwksCache.claimRefresh}, one per
|
|
567
|
+
* {@link OIDC_JWKS_MIN_REFRESH_SECONDS} per key endpoint, for every `kid` together rather than per `kid`.
|
|
568
|
+
* Per `kid` would be no bound at all: an attacker has as many of those as they care to type.
|
|
569
|
+
*
|
|
570
|
+
* ## What a miss inside the window answers, and why it is two different codes
|
|
571
|
+
*
|
|
572
|
+
* Whatever we hold is what the issuer published a moment ago, so a `kid` outside it is a `kid` the issuer
|
|
573
|
+
* does not publish — the sender's failure, 401, audited, and not worth retrying. **Unless we hold nothing**,
|
|
574
|
+
* which means the last ask inside this window failed or has not come back: then the honest answer is that we
|
|
575
|
+
* could not look, and that is the issuer's 502. Neither answer costs an outbound request, which is the point.
|
|
576
|
+
*
|
|
577
|
+
* With no store at all there is nothing to claim and nothing to hold, so every call fetches. That is
|
|
578
|
+
* {@link verifyOidcToken}'s direct-call shape — one caller, one token it already decided to check — and
|
|
579
|
+
* never the guard's, which builds a store precisely so this paragraph does not describe it.
|
|
493
580
|
*/
|
|
494
581
|
async function resolveKey(kid: string, options: VerifyOidcTokenOptions): Promise<OidcJwk> {
|
|
495
|
-
const
|
|
582
|
+
const cache = options.jwksCache;
|
|
583
|
+
const held = await cache?.get(options.jwksUrl);
|
|
496
584
|
const cached = held?.find((key) => key.kid === kid);
|
|
497
585
|
if (cached) return cached;
|
|
498
586
|
|
|
587
|
+
if (cache !== undefined && !(await cache.claimRefresh(options.jwksUrl, OIDC_JWKS_MIN_REFRESH_SECONDS))) {
|
|
588
|
+
if (held === undefined || held.length === 0) {
|
|
589
|
+
throw upstream(
|
|
590
|
+
`The OIDC key endpoint ${options.jwksUrl} was asked within the last ${OIDC_JWKS_MIN_REFRESH_SECONDS} seconds and nothing is held, so the token's kid could not be looked up.`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
throw unverified(
|
|
594
|
+
`no key the issuer publishes matches the token's kid ${snippet(kid)}, and the published set was read within the last ${OIDC_JWKS_MIN_REFRESH_SECONDS} seconds.`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
499
598
|
const refreshed = await fetchJwks(options);
|
|
500
|
-
await
|
|
599
|
+
await cache?.set(options.jwksUrl, refreshed, options.jwksTtlSeconds ?? OIDC_JWKS_TTL_SECONDS);
|
|
501
600
|
const found = refreshed.find((key) => key.kid === kid);
|
|
502
601
|
if (!found) {
|
|
503
602
|
throw unverified(`no key the issuer publishes matches the token's kid ${snippet(kid)}.`);
|
|
@@ -541,17 +640,33 @@ async function fetchJwks(options: VerifyOidcTokenOptions): Promise<OidcJwk[]> {
|
|
|
541
640
|
return parsed.data.keys;
|
|
542
641
|
}
|
|
543
642
|
|
|
544
|
-
/**
|
|
643
|
+
/**
|
|
644
|
+
* Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto.
|
|
645
|
+
*
|
|
646
|
+
* **Both refusals are the sender's, 401, and the reason is who chose the key.** A published key that cannot
|
|
647
|
+
* verify an `RS256` signature looks like the issuer's fact, and as a fact about the key set it is — but the
|
|
648
|
+
* key reached here because a `kid` in an unverified header selected it, so as an *answer* it is attacker-
|
|
649
|
+
* chosen. A 502 hands that caller three things it must not have: a code the webhook guard passes through
|
|
650
|
+
* untouched, so the refusal never reaches the audit trail that exists to show a rail being probed; a status
|
|
651
|
+
* a sender is invited to retry, which for Pub/Sub means forever; and a lever for picking which of those two
|
|
652
|
+
* an endpoint answers. The narrower reading is also the true one — a token naming a key that cannot check its
|
|
653
|
+
* own algorithm is a token nothing can verify, which is what `core/webhook_unverified` says.
|
|
654
|
+
*
|
|
655
|
+
* The issuer's own failures stay the issuer's: {@link fetchJwks} is 502 for every one of them, and no `kid`
|
|
656
|
+
* decides whether they happen.
|
|
657
|
+
*/
|
|
545
658
|
async function importKey(jwk: OidcJwk): Promise<CryptoKey> {
|
|
546
659
|
if (jwk.kty !== "RSA") {
|
|
547
|
-
throw
|
|
660
|
+
throw unverified(
|
|
661
|
+
`the token's kid ${snippet(jwk.kid)} names a ${snippet(jwk.kty)} key, and only RSA keys sign an OIDC token.`,
|
|
662
|
+
);
|
|
548
663
|
}
|
|
549
664
|
try {
|
|
550
665
|
// `alg` is forced to `RS256` rather than taken from the published key: the algorithm is already pinned by
|
|
551
666
|
// the header literal, and a key set that published a different one would otherwise decide it for us.
|
|
552
667
|
return await crypto.subtle.importKey("jwk", { ...jwk, alg: "RS256" }, RS256, false, ["verify"]);
|
|
553
668
|
} catch (cause) {
|
|
554
|
-
throw
|
|
669
|
+
throw unverified(`the key the token's kid ${snippet(jwk.kid)} names could not be imported.`, cause);
|
|
555
670
|
}
|
|
556
671
|
}
|
|
557
672
|
|
package/src/version.generated.ts
CHANGED