@pithy-sh/core 0.2.0 → 0.3.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.
Files changed (68) hide show
  1. package/dist/capability/bindings.d.ts +5 -0
  2. package/dist/capability/bindings.d.ts.map +1 -1
  3. package/dist/capability/bindings.js +44 -3
  4. package/dist/capability/capability.d.ts +33 -0
  5. package/dist/capability/capability.d.ts.map +1 -1
  6. package/dist/capability/manifest.d.ts +5 -0
  7. package/dist/capability/manifest.d.ts.map +1 -1
  8. package/dist/capability/manifest.js +1 -1
  9. package/dist/capability/validateBindings.js +1 -1
  10. package/dist/controlPlane/http/cors.js +1 -1
  11. package/dist/createBackend.d.ts +2 -1
  12. package/dist/createBackend.d.ts.map +1 -1
  13. package/dist/createBackend.js +5 -4
  14. package/dist/error/http.d.ts +27 -5
  15. package/dist/error/http.d.ts.map +1 -1
  16. package/dist/error/http.js +33 -9
  17. package/dist/error/messageParams.d.ts +28 -0
  18. package/dist/error/messageParams.d.ts.map +1 -0
  19. package/dist/error/messageParams.js +31 -0
  20. package/dist/error/terminal.d.ts +16 -5
  21. package/dist/error/terminal.d.ts.map +1 -1
  22. package/dist/error/terminal.js +16 -5
  23. package/dist/http/oidcWebhook.d.ts +283 -0
  24. package/dist/http/oidcWebhook.d.ts.map +1 -0
  25. package/dist/http/oidcWebhook.js +414 -0
  26. package/dist/http/signedWebhook.d.ts +9 -65
  27. package/dist/http/signedWebhook.d.ts.map +1 -1
  28. package/dist/http/signedWebhook.js +19 -9
  29. package/dist/http/webhookWindow.d.ts +33 -0
  30. package/dist/http/webhookWindow.d.ts.map +1 -0
  31. package/dist/http/webhookWindow.js +35 -0
  32. package/dist/logger/errorHandler.d.ts +11 -0
  33. package/dist/logger/errorHandler.d.ts.map +1 -0
  34. package/dist/logger/errorHandler.js +87 -0
  35. package/dist/naming/environment.js +1 -1
  36. package/dist/naming/provisionScope.d.ts +108 -3
  37. package/dist/naming/provisionScope.d.ts.map +1 -1
  38. package/dist/naming/provisionScope.js +27 -2
  39. package/dist/naming/resource.js +1 -1
  40. package/dist/naming/resourceNames.js +1 -1
  41. package/dist/schema/describedness.d.ts +30 -1
  42. package/dist/schema/describedness.d.ts.map +1 -1
  43. package/dist/schema/describedness.js +42 -6
  44. package/dist/test-utils/oidcFixtures.d.ts +93 -0
  45. package/dist/test-utils/oidcFixtures.d.ts.map +1 -0
  46. package/dist/test-utils/oidcFixtures.js +111 -0
  47. package/dist/version.generated.d.ts +1 -1
  48. package/dist/version.generated.js +1 -1
  49. package/dist/workflow/naming.js +1 -1
  50. package/dist/workflow/stepMessage.d.ts +28 -3
  51. package/dist/workflow/stepMessage.d.ts.map +1 -1
  52. package/dist/workflow/stepMessage.js +48 -6
  53. package/package.json +1 -1
  54. package/src/capability/bindings.ts +49 -0
  55. package/src/capability/capability.ts +34 -0
  56. package/src/createBackend.ts +7 -3
  57. package/src/error/http.ts +34 -10
  58. package/src/error/messageParams.ts +38 -0
  59. package/src/error/terminal.ts +16 -5
  60. package/src/http/oidcWebhook.ts +672 -0
  61. package/src/http/signedWebhook.ts +44 -16
  62. package/src/http/webhookWindow.ts +35 -0
  63. package/src/logger/errorHandler.ts +94 -0
  64. package/src/naming/provisionScope.ts +150 -4
  65. package/src/schema/describedness.ts +51 -6
  66. package/src/test-utils/oidcFixtures.ts +138 -0
  67. package/src/version.generated.ts +1 -1
  68. package/src/workflow/stepMessage.ts +52 -6
@@ -0,0 +1,672 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { MiddlewareHandler } from "hono";
5
+ import { z } from "zod";
6
+ import type { PithyHonoEnv } from "../capability/capability";
7
+ import { InternalError, UpstreamError, WebhookUnverifiedError } from "../error/pithyError";
8
+
9
+ /**
10
+ * The `signed-webhook` strategy for a sender that proves itself with an **OIDC token** rather than an HMAC.
11
+ * Issuers, key endpoint, audience and a claims predicate in; the verified claims, or a `PithyError`, out.
12
+ *
13
+ * Two mechanisms, one declared strategy. {@link ./verification.VerificationStrategy} already says as much —
14
+ * *"an HMAC, a signed JWS chain, an OIDC token… a rail whose sender proves it differently implements its
15
+ * own"* — so this sits beside {@link ./signedWebhook.requireSignedWebhook} and wears its seams, rather than
16
+ * adding a word to that union. What changes is where the proof comes from: with an HMAC both ends hold a
17
+ * secret, and with OIDC neither end holds anything. The sender asks its identity provider for a token that
18
+ * expires in minutes; the receiver asks the same provider for the public keys that check it.
19
+ *
20
+ * ## The claims check is the boundary, not the signature
21
+ *
22
+ * An identity provider signs tokens for **everybody** with the same handful of keys. GitHub signs one for
23
+ * every repository on the planet; Google signs one for every Pub/Sub push subscription. So a valid signature
24
+ * proves only *that provider minted this*, which any of their users can arrange in under a minute. What makes
25
+ * a token **ours** is what it claims: `aud`, which the sender names when it mints the token and which is the
26
+ * endpoint it was minted for, and whatever the {@link OidcWebhookScheme.claims} predicate asserts on top —
27
+ * `sub === "repo:pithy-sh/pithy:environment:npm-publish"` for a release job, an issuer's service account for
28
+ * a push subscription. Verify the signature, skip those, and the result is an endpoint every user of that
29
+ * provider can post to.
30
+ *
31
+ * That is why `claims` is a **predicate the route holds** rather than a value in configuration. It is the
32
+ * same constraint as a `ValueRotator`: the declaration is the `signed-webhook` tag on the route, and the code
33
+ * that decides who is allowed lives with the route, where it can be read beside the handler it protects.
34
+ *
35
+ * ## Four steps, in this order, and the order is the security property
36
+ *
37
+ * 1. **Pin the algorithm before touching a key.** `alg` arrives in a header nobody has verified yet, so it is
38
+ * parsed against a literal `RS256` rather than looked up. That is what makes the two published confusions
39
+ * unreachable — `none`, which asks for the signature to be skipped, and `HS256`, which asks the verifier to
40
+ * HMAC with the *public* key as if it were a shared secret. Neither survives a literal.
41
+ * 2. **Resolve the key by `kid` from the issuer's published set.** Never from the token, which is why there is
42
+ * no `jwk`/`x5u` handling here: a token that carries its own key is a token that verifies itself.
43
+ * 3. **Verify the signature over the exact received segments** — `header.claims` as encoded, never a
44
+ * re-serialization. Key order and whitespace belong to the sender, not to us.
45
+ * 4. **Then read the claims,** and only then. A claim from an unverified token is a claim an attacker wrote,
46
+ * so a verifier that reads `iss` or `sub` to decide *which key to fetch* has already trusted the forgery it
47
+ * is about to check. Issuer, audience, expiry, and the caller's predicate are all after the signature.
48
+ *
49
+ * ## What a refusal says
50
+ *
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. The step goes in `detail`, which the HTTP codec
53
+ * strips, so an operator reading a log learns which check refused it and the sender learns only that
54
+ * something did. Values that decide something are named there (`kid`, `iss`, `aud`) because a misconfigured
55
+ * audience is the realistic failure and an operator cannot fix what the log will not name — truncated by
56
+ * {@link snippet}, and never the signature and never the whole token.
57
+ *
58
+ * Two failures are deliberately **not** the sender's. An endpoint with no issuers, no audience, no key
59
+ * endpoint, or a clock that is not a clock is a configuration fault and takes `core/internal` — reporting it
60
+ * as an unverified webhook sends an operator hunting a forger who is not there. And a key endpoint that
61
+ * cannot be reached or answers nonsense is `core/upstream_failed` (502), because a 500 tells an operator to
62
+ * read *our* logs and the thing that broke is the issuer's. Both still refuse the delivery: fail closed is
63
+ * about what happens to the request, not about whose code goes on it.
64
+ *
65
+ * ## What this proves, and what it does not
66
+ *
67
+ * The same caveat as the HMAC scheme, for the same reason: this proves a delivery is authentic and fresh,
68
+ * never that it is *new*. A captured token replays until it expires. A handler that grants, charges, or
69
+ * deletes needs its own uniqueness key.
70
+ */
71
+
72
+ /** How long a fetched key set is reused. Issuers rotate slowly, and an unknown `kid` forces a refresh anyway. */
73
+ export const OIDC_JWKS_TTL_SECONDS = 3600;
74
+
75
+ /** Tolerance on `exp`, `nbf` and `iat`. A minute covers clock drift; an hour would cover a replay. */
76
+ export const OIDC_CLOCK_SKEW_SECONDS = 60;
77
+
78
+ /**
79
+ * The most tolerance this verifier accepts, and it refuses above rather than clamping.
80
+ *
81
+ * A skew is a replay window in the plainest possible units: every second of it is a second longer a captured
82
+ * token keeps working. Five minutes is the far end of real drift between two machines that both run NTP; past
83
+ * that a number has stopped covering drift and started covering a capture, which is what the sentence above
84
+ * says about an hour. Refused rather than clamped because a `604800` is a typo or a misunderstanding either
85
+ * way — clamped, it boots and nobody reads the line again; refused, it is a red boot and a two-character fix.
86
+ */
87
+ export const OIDC_MAX_CLOCK_SKEW_SECONDS = 300;
88
+
89
+ /** The header a bearer token arrives in, unless a sender names another. Lower case, as Hono presents one. */
90
+ const DEFAULT_TOKEN_HEADER = "authorization";
91
+
92
+ /** RSASSA-PKCS1-v1_5 with SHA-256 — the one algorithm `alg: RS256` names, fixed here rather than derived. */
93
+ const RS256 = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } as const;
94
+
95
+ /** The unpadded url alphabet (RFC 4648 §5). Whitespace, `+`, `/` and `=` are all outside it, deliberately. */
96
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
97
+
98
+ /** How much of a token-supplied value a refusal may quote. Enough to diagnose, too little to fill a log. */
99
+ const SNIPPET_LENGTH = 120;
100
+
101
+ /**
102
+ * The header of an OIDC token, as tightly as it can be stated. `alg` is a literal for the reason in the
103
+ * module doc; `kid` is required because a key is resolved by it and a token that names no key cannot be
104
+ * checked against one. `.loose()` because a provider may add a field and a real token must not be refused
105
+ * for carrying something new.
106
+ */
107
+ const OidcTokenHeader = z
108
+ .object({
109
+ alg: z
110
+ .literal("RS256")
111
+ .describe(
112
+ "The signing algorithm. A literal, not a lookup: `alg` arrives in an unverified header, and accepting `none` or `HS256` from it is how JWS verifiers are bypassed.",
113
+ ),
114
+ kid: z
115
+ .string()
116
+ .min(1)
117
+ .describe("Which of the issuer's published keys signed the token. Resolved against the issuer's key set."),
118
+ })
119
+ .loose()
120
+ .describe("The header of an OIDC token, constrained to the one shape this verifier accepts.");
121
+
122
+ /**
123
+ * One published RSA verification key, in the shape a JWKS endpoint returns. `.loose()` because providers add
124
+ * fields, and a key set must not be rejected for carrying something this build does not read.
125
+ */
126
+ export const OidcJwk = z
127
+ .object({
128
+ kid: z.string().min(1).describe("The key id a token header names."),
129
+ kty: z.string().min(1).describe("The key type. Only `RSA` is accepted — an OIDC token is signed with one."),
130
+ n: z.string().min(1).describe("The RSA modulus, base64url."),
131
+ e: z.string().min(1).describe("The RSA public exponent, base64url."),
132
+ alg: z.string().min(1).optional().describe("The algorithm the key is published for, when stated."),
133
+ use: z.string().min(1).optional().describe("What the key is published for — `sig` for a signing key."),
134
+ })
135
+ .loose()
136
+ .describe("One published OIDC verification key, as a JWKS endpoint returns it.");
137
+ export type OidcJwk = z.infer<typeof OidcJwk>;
138
+
139
+ /** A published key set. At least one key: an empty set is an endpoint that is not answering properly. */
140
+ export const OidcJwks = z
141
+ .object({
142
+ keys: z.array(OidcJwk).min(1).describe("Every key the issuer currently publishes for signature verification."),
143
+ })
144
+ .loose()
145
+ .describe("The response of an issuer's JWKS endpoint.");
146
+ export type OidcJwks = z.infer<typeof OidcJwks>;
147
+
148
+ /**
149
+ * The claims an OIDC token carries, narrowed to the ones that decide anything. `.loose()` keeps the rest —
150
+ * the predicate reads them, and a claim set must not be refused for being richer than expected.
151
+ *
152
+ * `aud` is a **string and only a string**, though RFC 7519 permits an array. The audience check is the
153
+ * boundary, and an array turns an equality into a set membership — one more shape for a verifier to be wrong
154
+ * about, on the check the whole scheme rests on. No sender the kit talks to mints one.
155
+ */
156
+ export const OidcClaims = z
157
+ .object({
158
+ iss: z.string().min(1).describe("Who issued the token. Checked against the configured issuers, and nothing else."),
159
+ aud: z
160
+ .string()
161
+ .min(1)
162
+ .describe(
163
+ "Who the token was minted for — the endpoint the sender named when it asked for the token. The claim that makes a token ours rather than merely the issuer's.",
164
+ ),
165
+ sub: z
166
+ .string()
167
+ .min(1)
168
+ .optional()
169
+ .describe(
170
+ "Which identity the issuer minted the token for — a workflow's `repo:owner/name:environment:x`, a service account. Read by the caller's `claims` predicate, never by this module.",
171
+ ),
172
+ exp: z.number().int().describe("When the token expires, in seconds since the epoch."),
173
+ nbf: z.number().int().optional().describe("The earliest the token may be used, in seconds since the epoch."),
174
+ iat: z.number().int().optional().describe("When the token was issued, in seconds since the epoch."),
175
+ })
176
+ .loose()
177
+ .describe("The claims on an OIDC token, narrowed to the ones verification depends on.");
178
+ export type OidcClaims = z.infer<typeof OidcClaims>;
179
+
180
+ /** The response shape this module reads. Structural, so a test's transport need not be a whole `Response`. */
181
+ export interface OidcJwksResponse {
182
+ /** Whether the status is 2xx. */
183
+ ok: boolean;
184
+ /** The status code, which is what decides the mapping. */
185
+ status: number;
186
+ /** The body as text. Read as text rather than JSON so a non-JSON answer is a diagnosis, not a throw. */
187
+ text(): Promise<string>;
188
+ }
189
+
190
+ /**
191
+ * The HTTP seam the key endpoint is reached through.
192
+ *
193
+ * Injectable for exactly one reason: **no test may reach a live identity provider**, and a verifier whose
194
+ * network call could not be substituted would have to be tested through a stub of itself, which proves
195
+ * nothing. One explicit parameter rather than a reassigned global — a global stub leaks between suites and
196
+ * hides which module was actually exercised.
197
+ */
198
+ export type OidcJwksFetch = (url: string) => Promise<OidcJwksResponse>;
199
+
200
+ /** The default transport: the runtime's own `fetch`. */
201
+ export const oidcJwksFetch: OidcJwksFetch = (url) => fetch(url) as unknown as Promise<OidcJwksResponse>;
202
+
203
+ /**
204
+ * Where a fetched key set is held between requests.
205
+ *
206
+ * **Injected, never a module global.** A cache in a module variable is one cache for the whole isolate that
207
+ * no caller owns, which is how `resetGoogleJwksCache` came to exist: once the state is unreachable, a test
208
+ * suite needs an exported function whose only purpose is to undo the previous suite. Passing the store in
209
+ * deletes that function and the class of bug it papers over — a test seeds its own, and a Worker gives one
210
+ * over KV or the Cache API and shares it across isolates.
211
+ *
212
+ * **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 cache, a 49-byte
214
+ * forged token naming a published `kid` buys a round trip to the issuer, 1:1, until the issuer rate-limits us
215
+ * and the real deliveries 502 alongside the forgeries. {@link verifyOidcToken} called directly is the other
216
+ * case and does fetch per call — its caller holds one token it has already decided to check, so there is no
217
+ * flood to absorb and no cache to acquire by surprise.
218
+ *
219
+ * 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 cache buys is one round trip per TTL instead of one per delivery.
221
+ */
222
+ export interface JwksCache {
223
+ /** The key set held for `url`, or `undefined` when nothing usable is held. */
224
+ get(url: string): Promise<readonly OidcJwk[] | undefined>;
225
+ /** Hold a freshly fetched key set for `url` for `ttlSeconds`. */
226
+ set(url: string, keys: readonly OidcJwk[], ttlSeconds: number): Promise<void>;
227
+ }
228
+
229
+ /**
230
+ * A key cache in memory, expiring on a clock the caller owns.
231
+ *
232
+ * One object per call, so two callers never share one by accident and a test's cache dies with its test. The
233
+ * clock is injected for the same reason it is everywhere else here: an expiry that depends on wall time is an
234
+ * assertion nobody can write.
235
+ */
236
+ export function memoryJwksCache(options: { now?: () => Date } = {}): JwksCache {
237
+ const now = options.now ?? (() => new Date());
238
+ const held = new Map<string, { keys: readonly OidcJwk[]; expiresAt: number }>();
239
+ return {
240
+ get: async (url) => {
241
+ const entry = held.get(url);
242
+ if (entry === undefined) return undefined;
243
+ if (entry.expiresAt <= now().getTime()) {
244
+ held.delete(url);
245
+ return undefined;
246
+ }
247
+ return entry.keys;
248
+ },
249
+ set: async (url, keys, ttlSeconds) => {
250
+ held.set(url, { keys, expiresAt: now().getTime() + ttlSeconds * 1000 });
251
+ },
252
+ };
253
+ }
254
+
255
+ /** What the two ends of the scheme must agree on, and what the receiver decides for itself. */
256
+ export interface OidcWebhookScheme {
257
+ /**
258
+ * The `iss` values this endpoint accepts. More than one only where a provider publishes more than one
259
+ * spelling of itself — Google issues under both `accounts.google.com` and `https://accounts.google.com`;
260
+ * GitHub uses one. Empty is a configuration fault, never "accept anything".
261
+ */
262
+ issuers: readonly string[];
263
+ /** Where the issuer publishes the keys that sign its tokens. Public, so it is configuration and not a secret. */
264
+ jwksUrl: string;
265
+ /** The `aud` the token must claim — the value the sender named when it minted one. The real boundary. */
266
+ audience: string;
267
+ /**
268
+ * Whatever else makes a token *this endpoint's*: `(claims) => claims.sub === "repo:owner/name:environment:x"`.
269
+ *
270
+ * A predicate rather than configuration, because it cannot cross into `pithy.manifest.json` and should not
271
+ * try: the manifest declares the strategy, the route holds the rule. Run **last**, after the signature and
272
+ * every standard claim, so it is only ever handed claims that are already proven to be the issuer's.
273
+ */
274
+ claims: (claims: OidcClaims) => boolean | Promise<boolean>;
275
+ /**
276
+ * Tolerance on `exp`, `nbf` and `iat`, in seconds. Defaults to {@link OIDC_CLOCK_SKEW_SECONDS} and must be
277
+ * a finite number from 0 to {@link OIDC_MAX_CLOCK_SKEW_SECONDS}; anything else is `core/internal`, because
278
+ * this is the second operand of all three freshness comparisons and one `NaN` disables them together.
279
+ */
280
+ skewSeconds?: number;
281
+ /**
282
+ * How long a fetched key set is held. Defaults to {@link OIDC_JWKS_TTL_SECONDS} and must be a finite number
283
+ * of seconds — a `NaN` is an entry that never expires, which pins a key the issuer has rotated out.
284
+ */
285
+ jwksTtlSeconds?: number;
286
+ /**
287
+ * Where fetched keys are held between deliveries. Never a module global: {@link requireOidcWebhook} builds
288
+ * one per guard when a route gives none, and {@link verifyOidcToken} called directly fetches per call.
289
+ */
290
+ jwksCache?: JwksCache;
291
+ /** The HTTP seam the key endpoint is reached through. Defaults to the runtime's `fetch`. */
292
+ transport?: OidcJwksFetch;
293
+ }
294
+
295
+ /** Everything {@link verifyOidcToken} needs beyond the token itself. */
296
+ export interface VerifyOidcTokenOptions extends OidcWebhookScheme {
297
+ /** The clock. Injectable so an expiry refusal is deterministic rather than wall-clock dependent. */
298
+ now?: Date;
299
+ }
300
+
301
+ /** Everything {@link requireOidcWebhook} needs, as a route wears it. */
302
+ export interface OidcWebhookGuardOptions extends OidcWebhookScheme {
303
+ /** The header the token arrives in, lower case. Defaults to `authorization`, carrying `Bearer <token>`. */
304
+ header?: string;
305
+ /** The clock, for the freshness checks. A function because a guard is built once and used per request. */
306
+ now?: () => Date;
307
+ }
308
+
309
+ /**
310
+ * Verify one OIDC token and return its claims. Throws, never reports.
311
+ *
312
+ * The steps run in the order the module doc gives, and that order **is** the security property: algorithm
313
+ * pinned, key resolved by `kid` from the published set, signature checked over the received bytes, and only
314
+ * then a single claim read. A verifier that reads a claim to decide which key to fetch has trusted the thing
315
+ * it has not checked yet.
316
+ *
317
+ * @throws {@link WebhookUnverifiedError} (401) when the sender did not prove itself — unreadable token,
318
+ * unknown key, bad signature, wrong issuer or audience, expired, not yet valid, predicate false.
319
+ * @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.
321
+ */
322
+ export async function verifyOidcToken(token: string, options: VerifyOidcTokenOptions): Promise<OidcClaims> {
323
+ // Ours before theirs. An endpoint with nothing to check against refuses every delivery, and calling that an
324
+ // unverified webhook would send an operator after a forger while the real answer is an empty config value.
325
+ const issuers = options.issuers.filter((issuer) => issuer.length > 0);
326
+ if (issuers.length === 0 || options.audience.length === 0 || options.jwksUrl.length === 0) {
327
+ throw misconfigured(
328
+ "Give the OIDC webhook guard an issuer, an audience, and a JWKS URL.",
329
+ "The OIDC webhook guard resolved no issuer, no audience, or no key endpoint, so it can verify nothing.",
330
+ );
331
+ }
332
+ const seconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
333
+ // Fail closed on a clock that is not one. Every comparison below is `>` or `<` against this number, and
334
+ // NaN answers false to both — an invalid Date would wave every expired token through, in the one function
335
+ // whose whole argument is that it fails closed. Only a caller-supplied `now` can reach here as NaN, so it
336
+ // is our fault and takes our code, like the configuration above.
337
+ if (!Number.isFinite(seconds)) {
338
+ throw misconfigured(
339
+ "Give the OIDC webhook guard a valid clock.",
340
+ "The OIDC webhook guard was handed a clock that is not a valid Date, so no token's freshness can be judged.",
341
+ );
342
+ }
343
+ // The *other* operand of every one of those comparisons, and unguarded it fails open in exactly the same
344
+ // way — worse, because `exp + NaN < seconds` is false for a token that expired a second ago and for one
345
+ // that expired last year alike, so one unchecked number turns `exp`, `nbf` and `iat` into no-ops together.
346
+ // The realistic source is a route reading `Number(env.OIDC_SKEW_SECONDS)` for a variable nobody set:
347
+ // TypeScript types that `number` and nothing downstream looks again. Guarding one side of a comparison and
348
+ // not the other is not a guard. The upper bound is the same argument as the constant it defaults to — a
349
+ // tolerance is a replay window, so it is configuration that has to be small, not merely present.
350
+ const skew = options.skewSeconds ?? OIDC_CLOCK_SKEW_SECONDS;
351
+ if (!Number.isFinite(skew) || skew < 0 || skew > OIDC_MAX_CLOCK_SKEW_SECONDS) {
352
+ throw misconfigured(
353
+ `Give the OIDC webhook guard a clock skew between 0 and ${OIDC_MAX_CLOCK_SKEW_SECONDS} seconds.`,
354
+ `The OIDC webhook guard was given a clock skew of ${String(skew)} seconds, and a tolerance must be a number from 0 to ${OIDC_MAX_CLOCK_SKEW_SECONDS}.`,
355
+ );
356
+ }
357
+ // The same class again, one field over: a non-finite lifetime makes a cached entry expire at NaN, and
358
+ // `NaN <= now` is false forever — so a key set the issuer has since rotated out stays trusted for the life
359
+ // of the isolate. A cache that never expires is not a fast cache, it is a key pin nobody chose.
360
+ const jwksTtlSeconds = options.jwksTtlSeconds ?? OIDC_JWKS_TTL_SECONDS;
361
+ if (!Number.isFinite(jwksTtlSeconds) || jwksTtlSeconds < 0) {
362
+ throw misconfigured(
363
+ "Give the OIDC webhook guard a key-set lifetime of zero seconds or more.",
364
+ `The OIDC webhook guard was given a key-set lifetime of ${String(jwksTtlSeconds)} seconds, which would never expire.`,
365
+ );
366
+ }
367
+
368
+ const { head, body, mac } = splitCompactJws(token);
369
+
370
+ const header = OidcTokenHeader.safeParse(decodeJwtJson(head, "the token header"));
371
+ if (!header.success) {
372
+ throw unverified(`the token header is not one this endpoint accepts — ${issues(header.error)}.`);
373
+ }
374
+
375
+ const key = await importKey(await resolveKey(header.data.kid, options));
376
+ const verified = await crypto.subtle.verify(
377
+ RS256.name,
378
+ key,
379
+ // Copied, not passed through. `crypto.subtle.verify` takes a `BufferSource`, and a view onto a
380
+ // `SharedArrayBuffer` is bytes another thread can still be writing while the comparison runs.
381
+ new Uint8Array(decodeBase64Url(mac, "the token signature")) as unknown as ArrayBuffer,
382
+ new TextEncoder().encode(`${head}.${body}`) as unknown as ArrayBuffer,
383
+ );
384
+ if (!verified) {
385
+ throw unverified(`the token signature does not match the signed segments under key ${snippet(header.data.kid)}.`);
386
+ }
387
+
388
+ // Only now. Everything above proves the issuer minted this token; everything below proves it was minted
389
+ // for us. A claim read before this line is a claim whoever sent the request chose.
390
+ const parsed = OidcClaims.safeParse(decodeJwtJson(body, "the token claims"));
391
+ if (!parsed.success) {
392
+ throw unverified(`the token claims are not those of an OIDC token — ${issues(parsed.error)}.`);
393
+ }
394
+ const claims = parsed.data;
395
+
396
+ if (!issuers.includes(claims.iss)) {
397
+ throw unverified(`the token issuer is ${snippet(claims.iss)}, which this endpoint does not accept.`);
398
+ }
399
+ // The boundary. Everything above proves the issuer signed it; this is what proves it was minted for us and
400
+ // not for another of that issuer's users — or for this project's other environment.
401
+ if (claims.aud !== options.audience) {
402
+ throw unverified(
403
+ `the token audience is ${snippet(claims.aud)}, and this endpoint expects ${snippet(options.audience)}.`,
404
+ );
405
+ }
406
+ if (claims.exp + skew < seconds) {
407
+ throw unverified(`the token expired at ${instant(claims.exp)}.`);
408
+ }
409
+ if (claims.nbf !== undefined && claims.nbf - skew > seconds) {
410
+ throw unverified(`the token is not valid before ${instant(claims.nbf)}.`);
411
+ }
412
+ if (claims.iat !== undefined && claims.iat - skew > seconds) {
413
+ throw unverified(`the token was issued at ${instant(claims.iat)}, which is in the future.`);
414
+ }
415
+
416
+ // Last, and handed only proven claims. This is where a route says who it accepts, so a false answer here is
417
+ // "the issuer minted this for somebody else", which is a different sentence from "this is not signed".
418
+ if (!(await options.claims(claims))) {
419
+ throw unverified(
420
+ `the token is the issuer's but its claims are not accepted by this endpoint — subject ${snippet(claims.sub ?? "none")}.`,
421
+ );
422
+ }
423
+
424
+ return claims;
425
+ }
426
+
427
+ /**
428
+ * The OIDC gate, as a route wears it: `app.post(path, requireOidcWebhook({…}), zValidator("json", Body,
429
+ * validationHook), handler)`.
430
+ *
431
+ * ## Why the header is read before anything else
432
+ *
433
+ * The bearer header is the cheapest thing to check and the commonest thing to be missing, so it goes first. A
434
+ * delivery carrying no token is refused whatever the body holds, and fetching a key set before looking would
435
+ * be unauthenticated work an anonymous POST could buy on the one route whose purpose is to refuse anonymous
436
+ * callers.
437
+ *
438
+ * ## Why a cache is not optional here
439
+ *
440
+ * A caller that presents *something* is past that first check, and the next thing the verifier needs is the
441
+ * 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 cache that has never held anything. A `kid` is
443
+ * published by definition, which makes a 49-byte token naming one the cheapest thing on the internet to send,
444
+ * and with no cache each one is a round trip to the issuer, 1:1. That is a bandwidth multiplier aimed at
445
+ * somebody else's endpoint, and once the issuer rate-limits us {@link fetchJwks} maps it to
446
+ * `core/upstream_failed` for the genuine deliveries too — an anonymous caller taking the boundary down by
447
+ * asking it politely. A route wanting a harder bound (shared across isolates, or rate-limiting) passes its
448
+ * own {@link OidcWebhookScheme.jwksCache}, and this one is never built.
449
+ *
450
+ * A `kid` **nobody** publishes still costs one fetch per delivery, cache or no cache, because an unknown key
451
+ * id is how rotation announces itself and declining to look is an outage. That bound is per delivery and
452
+ * lives in {@link resolveKey}; the cache is what collapses the other flood, the one naming a key that exists.
453
+ *
454
+ * ## Why nothing is published to the request
455
+ *
456
+ * The gate proves the caller and returns; it sets no request variable. The claims a route cares about are the
457
+ * ones its own {@link OidcWebhookScheme.claims} predicate already asserted, and a handler that needs the
458
+ * whole claim set calls {@link verifyOidcToken} directly rather than reading a variable it must remember to
459
+ * null-check.
460
+ *
461
+ * The body is never read here — unlike the HMAC gate, whose proof covers the bytes. An OIDC token proves who
462
+ * sent the request and says nothing about what they sent, so the route's `zValidator` is the only thing that
463
+ * touches the body, and it sees an untouched stream.
464
+ */
465
+ export function requireOidcWebhook(options: OidcWebhookGuardOptions): MiddlewareHandler<PithyHonoEnv> {
466
+ const { header = DEFAULT_TOKEN_HEADER, now, ...scheme } = options;
467
+ // Built with the guard and shared by every delivery it sees. On the guard's own clock, so a suite that
468
+ // injects one governs the expiry too rather than reaching wall time through the back door.
469
+ const jwksCache = scheme.jwksCache ?? memoryJwksCache({ now });
470
+ return async (c, next) => {
471
+ // The cheapest possible rejection, ahead of every cost an anonymous caller could otherwise buy.
472
+ const presented = c.req.header(header);
473
+ const token = presented === undefined ? undefined : bearerToken(presented);
474
+ if (token === undefined) {
475
+ throw unverified(`the delivery carries no ${header} header with a Bearer token.`);
476
+ }
477
+
478
+ await verifyOidcToken(token, { ...scheme, jwksCache, now: now?.() ?? new Date() });
479
+
480
+ await next();
481
+ };
482
+ }
483
+
484
+ /**
485
+ * The key a token's `kid` names, from the cache when it holds it and from the issuer otherwise.
486
+ *
487
+ * A `kid` the cache does not hold triggers **exactly one** refresh, which is what makes key rotation
488
+ * invisible: a new signing key appears in a token before anything tells us to look for it, and waiting out a
489
+ * TTL would be an outage. Exactly one, because otherwise a stream of forged tokens naming random `kid`s is a
490
+ * way to make us hammer the issuer — one delivery buys one fetch, whatever it claims. A caller that wants a
491
+ * harder bound than that gives a {@link JwksCache} that rate-limits, which is the other thing injecting the
492
+ * store buys.
493
+ */
494
+ async function resolveKey(kid: string, options: VerifyOidcTokenOptions): Promise<OidcJwk> {
495
+ const held = await options.jwksCache?.get(options.jwksUrl);
496
+ const cached = held?.find((key) => key.kid === kid);
497
+ if (cached) return cached;
498
+
499
+ const refreshed = await fetchJwks(options);
500
+ await options.jwksCache?.set(options.jwksUrl, refreshed, options.jwksTtlSeconds ?? OIDC_JWKS_TTL_SECONDS);
501
+ const found = refreshed.find((key) => key.kid === kid);
502
+ if (!found) {
503
+ throw unverified(`no key the issuer publishes matches the token's kid ${snippet(kid)}.`);
504
+ }
505
+ return found;
506
+ }
507
+
508
+ /**
509
+ * Fetch and validate the issuer's key set.
510
+ *
511
+ * Every failure is `core/upstream_failed`, and every failure still refuses the delivery. The code says whose
512
+ * system to look at — a 500 would send an operator into our logs for an outage at an endpoint we do not run,
513
+ * and a 401 would send them after a forger. The URL is named because it is our own configuration, not
514
+ * anything a caller supplied.
515
+ */
516
+ async function fetchJwks(options: VerifyOidcTokenOptions): Promise<OidcJwk[]> {
517
+ const transport = options.transport ?? oidcJwksFetch;
518
+ let response: OidcJwksResponse;
519
+ try {
520
+ response = await transport(options.jwksUrl);
521
+ } catch (cause) {
522
+ throw upstream(`The OIDC key endpoint ${options.jwksUrl} did not answer.`, cause);
523
+ }
524
+ if (!response.ok) {
525
+ throw upstream(`The OIDC key endpoint ${options.jwksUrl} answered ${response.status}.`);
526
+ }
527
+
528
+ let body: unknown;
529
+ try {
530
+ body = JSON.parse(await response.text()) as unknown;
531
+ } catch (cause) {
532
+ // An HTML error page behind a proxy is the realistic shape of this, and reading it as an empty key set
533
+ // would refuse every delivery with a sentence about the sender's `kid`.
534
+ throw upstream(`The OIDC key endpoint ${options.jwksUrl} answered a non-JSON body.`, cause);
535
+ }
536
+
537
+ const parsed = OidcJwks.safeParse(body);
538
+ if (!parsed.success) {
539
+ throw upstream(`The OIDC key endpoint ${options.jwksUrl} answered an unexpected shape — ${issues(parsed.error)}.`);
540
+ }
541
+ return parsed.data.keys;
542
+ }
543
+
544
+ /** Import one published JWK as a verification key. A non-RSA key is refused rather than handed to WebCrypto. */
545
+ async function importKey(jwk: OidcJwk): Promise<CryptoKey> {
546
+ if (jwk.kty !== "RSA") {
547
+ throw upstream(`Published key ${snippet(jwk.kid)} is ${snippet(jwk.kty)}, and only RSA keys sign an OIDC token.`);
548
+ }
549
+ try {
550
+ // `alg` is forced to `RS256` rather than taken from the published key: the algorithm is already pinned by
551
+ // the header literal, and a key set that published a different one would otherwise decide it for us.
552
+ return await crypto.subtle.importKey("jwk", { ...jwk, alg: "RS256" }, RS256, false, ["verify"]);
553
+ } catch (cause) {
554
+ throw upstream(`Published key ${snippet(jwk.kid)} could not be imported.`, cause);
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Split a compact JWS, refusing anything that is not three non-empty segments.
560
+ *
561
+ * The empty-segment check is the structural half of the algorithm pin: `alg: none` produces a header, a
562
+ * payload, and nothing after the final dot, so it is refused here before a header is even decoded.
563
+ */
564
+ function splitCompactJws(token: string): { head: string; body: string; mac: string } {
565
+ const segments = token.split(".");
566
+ if (segments.length !== 3) {
567
+ throw unverified(`the token has ${segments.length} segments, expected 3.`);
568
+ }
569
+ const [head, body, mac] = segments as [string, string, string];
570
+ if (head.length === 0 || body.length === 0 || mac.length === 0) {
571
+ throw unverified("a token segment is empty; an unsigned token is not accepted.");
572
+ }
573
+ return { head, body, mac };
574
+ }
575
+
576
+ /**
577
+ * Bytes from one unpadded base64url segment (RFC 4648 §5), strictly.
578
+ *
579
+ * A token segment is attacker-supplied by definition and `atob` is famously forgiving — it digests whitespace
580
+ * and, on some runtimes, characters that mean nothing. Silently decoding malformed input produces bytes that
581
+ * parse into *something*, and something is exactly what a forger wants a verifier to work with. Padding is
582
+ * refused too: RFC 7515 §2 mandates unpadded segments, and one token with two spellings is one more thing a
583
+ * comparison can be wrong about.
584
+ */
585
+ function decodeBase64Url(value: string, label: string): Uint8Array {
586
+ // A base64 group is 2, 3 or 4 characters. One leftover character encodes no byte at all, so a length of
587
+ // 1 mod 4 is unrepresentable rather than merely odd — refuse it before `atob` guesses.
588
+ if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) {
589
+ throw unverified(`${label} is not unpadded base64url.`);
590
+ }
591
+ const standard = value.replaceAll("-", "+").replaceAll("_", "/");
592
+ const padded = standard.padEnd(standard.length + ((4 - (standard.length % 4)) % 4), "=");
593
+ let binary: string;
594
+ try {
595
+ binary = atob(padded);
596
+ } catch (cause) {
597
+ throw unverified(`${label} failed base64url decode.`, cause);
598
+ }
599
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
600
+ }
601
+
602
+ /** One base64url JSON segment. `unknown` out — decoded bytes are not yet a known shape. */
603
+ function decodeJwtJson(segment: string, label: string): unknown {
604
+ const text = new TextDecoder().decode(decodeBase64Url(segment, label));
605
+ try {
606
+ return JSON.parse(text) as unknown;
607
+ } catch (cause) {
608
+ // Never echo the text. It is attacker-supplied on every request, and `detail` reaches an operator's logs.
609
+ throw unverified(`${label} is not JSON.`, cause);
610
+ }
611
+ }
612
+
613
+ /** Read `Bearer <token>` from a header value, or `undefined` when it is not one. The scheme is case-insensitive. */
614
+ function bearerToken(value: string): string | undefined {
615
+ const match = /^bearer +(\S+)$/i.exec(value.trim());
616
+ return match?.[1];
617
+ }
618
+
619
+ /** The sender's refusal. One code for every step; the step is in `detail`, which never reaches the sender. */
620
+ function unverified(detail: string, cause?: unknown): WebhookUnverifiedError {
621
+ return new WebhookUnverifiedError(
622
+ {
623
+ action: "Send a current OIDC token minted for this endpoint's audience.",
624
+ detail: `The OIDC webhook guard refused a delivery: ${detail}`,
625
+ },
626
+ cause === undefined ? undefined : { cause },
627
+ );
628
+ }
629
+
630
+ /**
631
+ * Ours, not the sender's. Every value this refuses on is one *we* wrote — an issuer list, an audience, a
632
+ * clock, a tolerance — so it takes `core/internal` and an `action` naming the knob to turn, rather than
633
+ * sending an operator after a forger who is not there. Still a refusal: the delivery is denied either way.
634
+ */
635
+ function misconfigured(action: string, detail: string): InternalError {
636
+ return new InternalError({ message: "This webhook endpoint is not configured.", action, detail });
637
+ }
638
+
639
+ /** The issuer's failure, not the sender's and not ours. Still a refusal — 502 denies the delivery. */
640
+ function upstream(detail: string, cause?: unknown): UpstreamError {
641
+ return new UpstreamError(
642
+ { action: "Check that this Worker can reach the issuer's JWKS endpoint.", detail },
643
+ cause === undefined ? undefined : { cause },
644
+ );
645
+ }
646
+
647
+ /** Zod issues as `path:code` pairs — never `message` or `received`, which would echo the token. */
648
+ function issues(error: z.ZodError): string {
649
+ return error.issues.map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`).join(", ");
650
+ }
651
+
652
+ /**
653
+ * One epoch-seconds claim, rendered for a refusal.
654
+ *
655
+ * The comparisons above are arithmetic and are right for any number `z.number().int()` admits — but
656
+ * `toISOString()` is not: `new Date(-1e15 * 1000)` is an invalid Date and **throws a `RangeError`**, which is
657
+ * a plain `Error` escaping a module whose every other exit is a `PithyError`. It is thrown while composing
658
+ * the *refusal message* for a token that has already been decided against, so the token is still refused —
659
+ * but through {@link requireOidcWebhook} the sender chooses whether they get a 401 or a 500, and a 500 sends
660
+ * an operator into our logs over somebody else's forgery. So a claim outside `Date`'s range is quoted as the
661
+ * number it is, and the refusal stays the refusal it already was.
662
+ */
663
+ function instant(epochSeconds: number): string {
664
+ const at = new Date(epochSeconds * 1000);
665
+ return Number.isFinite(at.getTime()) ? at.toISOString() : `epoch second ${epochSeconds}`;
666
+ }
667
+
668
+ /** A token-supplied value, quoted and bounded, so a refusal names what failed without becoming the log. */
669
+ function snippet(value: string): string {
670
+ const trimmed = value.length > SNIPPET_LENGTH ? `${value.slice(0, SNIPPET_LENGTH)}…` : value;
671
+ return JSON.stringify(trimmed);
672
+ }