@ultimat3/auth 11.1.0 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +24 -4
- package/package.json +4 -4
- package/src/jwks.ts +49 -13
- package/src/kdf-gate.ts +17 -34
package/CLAUDE.md
CHANGED
|
@@ -259,6 +259,21 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
259
259
|
and silently discarded a later caller's options, so an app pinning a corporate egress proxy got
|
|
260
260
|
it only if it called first — the `jobs.driver` shape, with a network path as the substituted
|
|
261
261
|
value. A caller that supplies options gets its own client.
|
|
262
|
+
- **A wedged JWKS refresh no longer holds the shared slot for ever** (`As of 2026-08-23`).
|
|
263
|
+
`createJwksClient` single-flights its refresh through `@ultimat3/core`'s `createSingleFlight`,
|
|
264
|
+
with `deadlineMs = timeoutMs * 2` — derived, never a second invented number: `timeoutMs` bounds
|
|
265
|
+
the network leg (`AbortSignal.timeout`) and everything after it is local work on the same budget,
|
|
266
|
+
so doubling never evicts a slow-but-healthy refresh. It matters because `AbortSignal.timeout`
|
|
267
|
+
bounds only the DEFAULT transport and `options.fetch` is the app's — a `fetch` that ignores its
|
|
268
|
+
signal used to pin the slot for the life of the process, and every later `keyFor` joined a
|
|
269
|
+
promise nothing would resolve. **Eviction frees the KEY, never the work**: the wedged refresh
|
|
270
|
+
runs on and its own callers keep their promise, so the worst case is one duplicate JWKS fetch and
|
|
271
|
+
never a failed verification. `schedule` is injectable so no test waits a deadline out.
|
|
272
|
+
Its consequence is the second half and is not optional: two refreshes can now overlap and both
|
|
273
|
+
end by installing what they read, so a `createFence` generation check is what stops the one the
|
|
274
|
+
client gave up on from dropping a pre-rotation key set on top of the live one. Read
|
|
275
|
+
(`fence.generation() === issued`), never `guard` — a superseded refresh is still the honest
|
|
276
|
+
answer for the callers holding it, and only the shared cache is fenced.
|
|
262
277
|
- **`verifyIdToken` checks `nbf` and `azp`.** `workload.ts` imports `ID_TOKEN_CLOCK_SKEW_MS` from
|
|
263
278
|
`id-token.ts` and then enforced a bound `id-token.ts` did not: an `nbf` ten years out verified.
|
|
264
279
|
`azp` is OIDC Core 3.1.3.7 step 5 — with more than one audience, `aud` naming this client says
|
|
@@ -301,8 +316,13 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
301
316
|
left was `http.maxInflight` (1000), about 19 GB of arenas queued. `kdf-gate.ts` bounds the width
|
|
302
317
|
(8) and the waiting queue (64) and refuses past it with `X_OVERLOADED`, borrowed from http and
|
|
303
318
|
listed in `AUTH_BORROWED_ERROR_CODES` — this package cannot import http, and a shed is a shed
|
|
304
|
-
whichever layer performs it.
|
|
305
|
-
|
|
319
|
+
whichever layer performs it. **The pool itself is `@ultimat3/core`'s `createFlightGate` since
|
|
320
|
+
2026-08-23** — the same hand-over-on-release rule, the same numbers — and the refusal stays this
|
|
321
|
+
package's through core's `overflow:` seam, so `kdfOverloaded` is still what a caller catches and
|
|
322
|
+
core's `X_FLIGHT_GATE_OVERLOADED` never leaves auth. No `subject:` is passed: it feeds only
|
|
323
|
+
`gateOverloaded`'s prose, which this gate never reaches. `configureKdfGate()` is the ONE install
|
|
324
|
+
point and is deliberately not a `defineAuth` key: the ceiling is a property of the machine, not
|
|
325
|
+
of the app's auth policy.
|
|
306
326
|
- **MFA has a first leg and no second one, and the second one is not a route you can just add.**
|
|
307
327
|
`login()` and `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists; nothing is
|
|
308
328
|
written, so the only value handed over is a user id in `meta`. A `POST /auth/mfa/verify
|
|
@@ -385,7 +405,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
385
405
|
| `oauth-builtins.ts` | the three shipped IdPs, as data. Imports only the type, so no cycle |
|
|
386
406
|
| `oauth-registry.ts` | the registry: `registerOAuthProvider`, `providerFor`, `oauthProviderIds` |
|
|
387
407
|
| `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` |
|
|
388
|
-
| `jwks.ts` | `crypto.subtle` signature verification, cached by `kid
|
|
408
|
+
| `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`, one shared in-flight refresh. No dependency |
|
|
389
409
|
| `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity` |
|
|
390
410
|
| `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` |
|
|
391
411
|
| `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
|
|
@@ -401,7 +421,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
401
421
|
| `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
|
|
402
422
|
| `oauth-errors.ts` | the OAuth half of those factories, and `restartAt`. Split off at the 500-line ceiling; declares no code and registers nothing |
|
|
403
423
|
| `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
|
|
404
|
-
| `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it |
|
|
424
|
+
| `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `createFlightGate` with auth's own refusal injected |
|
|
405
425
|
| `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
|
|
406
426
|
| `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
|
|
407
427
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "11.
|
|
35
|
-
"@ultimat3/db": "11.
|
|
36
|
-
"@ultimat3/schema": "11.
|
|
34
|
+
"@ultimat3/core": "11.2.0",
|
|
35
|
+
"@ultimat3/db": "11.2.0",
|
|
36
|
+
"@ultimat3/schema": "11.2.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/jwks.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// unverified JWT with the right `iss`, `aud` and a victim's `sub` is a full account takeover with
|
|
7
7
|
// no credential, so a signature check has to exist before those doors are opened.
|
|
8
8
|
|
|
9
|
-
import type { Clock } from '@ultimat3/core';
|
|
10
|
-
import { renderThrowable, systemClock } from '@ultimat3/core';
|
|
9
|
+
import type { Clock, Scheduler } from '@ultimat3/core';
|
|
10
|
+
import { createFence, createSingleFlight, renderThrowable, systemClock } from '@ultimat3/core';
|
|
11
11
|
import { decodeJwtSegment, isRecord } from './json';
|
|
12
12
|
import type { OAuthProvider } from './oauth';
|
|
13
13
|
import { oauthExchangeFailed, oauthTokenInvalid } from './oauth-errors';
|
|
@@ -43,8 +43,24 @@ export type IdTokenKeys = 'token-endpoint-tls' | JwksKeySource;
|
|
|
43
43
|
/** Short enough that a rotated key set is picked up on its own; a new `kid` refreshes early. */
|
|
44
44
|
export const DEFAULT_JWKS_TTL_MS = 10 * 60 * 1000;
|
|
45
45
|
|
|
46
|
+
/** One client is one key set, so the flight has exactly one key and its name is never read. */
|
|
47
|
+
const REFRESH_KEY = 'jwks';
|
|
48
|
+
|
|
46
49
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
47
50
|
|
|
51
|
+
/**
|
|
52
|
+
* How much longer than the TRANSPORT's own bound one refresh may hold the shared slot, before a
|
|
53
|
+
* later caller is allowed to start its own instead of joining it.
|
|
54
|
+
*
|
|
55
|
+
* Derived from `timeoutMs` rather than a second unrelated number, because the two answer the same
|
|
56
|
+
* question at two layers: `timeoutMs` is the network leg (`AbortSignal.timeout` below), and
|
|
57
|
+
* everything after it — reading the body, importing one `CryptoKey` per published JWK — is local
|
|
58
|
+
* work on the same budget. Doubling gives that second half as much wall clock as the first, so a
|
|
59
|
+
* slow-but-healthy refresh is never evicted, while a `fetch` that IGNORES its signal (which
|
|
60
|
+
* `options.fetch` is app-supplied and free to do) is let go of at twice its own stated bound.
|
|
61
|
+
*/
|
|
62
|
+
const JWKS_DEADLINE_FACTOR = 2;
|
|
63
|
+
|
|
48
64
|
export interface JwksClientOptions {
|
|
49
65
|
/** Named in every refusal this client throws. */
|
|
50
66
|
readonly provider: string;
|
|
@@ -55,6 +71,8 @@ export interface JwksClientOptions {
|
|
|
55
71
|
readonly clock?: Clock | undefined;
|
|
56
72
|
readonly ttlMs?: number | undefined;
|
|
57
73
|
readonly timeoutMs?: number | undefined;
|
|
74
|
+
/** Injected so the refresh deadline is provable without a test waiting one out. */
|
|
75
|
+
readonly schedule?: Scheduler | undefined;
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
const importParams = (alg: JwtAlgorithm): RsaHashedImportParams | EcKeyImportParams =>
|
|
@@ -94,22 +112,29 @@ const algorithmOf = (jwk: Record<string, unknown>): JwtAlgorithm | null => {
|
|
|
94
112
|
export function createJwksClient(options: JwksClientOptions): JwksKeySource {
|
|
95
113
|
const clock = options.clock ?? systemClock;
|
|
96
114
|
const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
|
|
115
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
97
116
|
let keys = new Map<string, CryptoKey>();
|
|
98
117
|
let fetchedAtMs = Number.NEGATIVE_INFINITY;
|
|
99
118
|
// When the last UNKNOWN-`kid` refresh ran, tracked apart from `fetchedAtMs` because that field
|
|
100
119
|
// is reset by every fetch, ordinary ones included — so gating the early refresh on it would let
|
|
101
120
|
// an attacker's own refresh authorise the next one.
|
|
102
121
|
let lastMissRefreshMs = Number.NEGATIVE_INFINITY;
|
|
103
|
-
|
|
122
|
+
// Two refreshes can overlap once the deadline below can evict one, and BOTH end by installing
|
|
123
|
+
// what they read. Without this, the later-settling of the two wins — so a refresh the client
|
|
124
|
+
// already gave up on could drop a pre-rotation key set on top of the live one, and every login
|
|
125
|
+
// against the new `kid` would start missing again. Read, never `guard`: a superseded refresh is
|
|
126
|
+
// still the honest answer for the callers holding it, and only the shared cache is fenced.
|
|
127
|
+
const fence = createFence('the published jwks key set');
|
|
104
128
|
|
|
105
129
|
const fetchKeys = async (): Promise<Map<string, CryptoKey>> => {
|
|
130
|
+
const issued = fence.bump();
|
|
106
131
|
const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
107
132
|
let response: Response;
|
|
108
133
|
try {
|
|
109
134
|
response = await doFetch(options.jwksUri, {
|
|
110
135
|
method: 'GET',
|
|
111
136
|
headers: { Accept: 'application/json' },
|
|
112
|
-
signal: AbortSignal.timeout(
|
|
137
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
113
138
|
});
|
|
114
139
|
} catch (error) {
|
|
115
140
|
throw oauthExchangeFailed({
|
|
@@ -145,19 +170,30 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
145
170
|
.catch(() => null);
|
|
146
171
|
if (key !== null) next.set(`${kid}:${alg}`, key);
|
|
147
172
|
}
|
|
148
|
-
|
|
149
|
-
|
|
173
|
+
if (fence.generation() === issued) {
|
|
174
|
+
keys = next;
|
|
175
|
+
fetchedAtMs = clock.now().getTime();
|
|
176
|
+
}
|
|
150
177
|
return next;
|
|
151
178
|
};
|
|
152
179
|
|
|
153
180
|
// One in-flight refresh shared by every concurrent caller: a cold cache under load is otherwise
|
|
154
181
|
// one outbound request per request in flight, against the IdP, at exactly the worst moment.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
182
|
+
//
|
|
183
|
+
// The deadline is why this is `@ultimat3/core`'s and not a local `inflight ??=`. A refresh that
|
|
184
|
+
// never settles used to hold the slot for the life of the process, and every later caller joined
|
|
185
|
+
// a promise nothing would resolve — `AbortSignal.timeout` bounds only the DEFAULT transport, and
|
|
186
|
+
// `options.fetch` is the app's. Eviction frees the KEY and nothing else: the wedged refresh keeps
|
|
187
|
+
// running, its own callers keep their promise, and the fence above stops its late answer from
|
|
188
|
+
// landing in the cache. So the worst case is one duplicate JWKS fetch, never a failed
|
|
189
|
+
// verification — which is what makes a deadline here a fix rather than a risk.
|
|
190
|
+
const flight = createSingleFlight({
|
|
191
|
+
deadlineMs: timeoutMs * JWKS_DEADLINE_FACTOR,
|
|
192
|
+
schedule: options.schedule,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const load = async (): Promise<Map<string, CryptoKey>> =>
|
|
196
|
+
await flight.run(REFRESH_KEY, fetchKeys);
|
|
161
197
|
|
|
162
198
|
const lookup = (current: Map<string, CryptoKey>, kid: string | null, alg: JwtAlgorithm) => {
|
|
163
199
|
if (kid !== null) return current.get(`${kid}:${alg}`) ?? null;
|
|
@@ -180,7 +216,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
180
216
|
// cannot shed it, because `auth` is pipeline stage 6 and `rate-limit` is stage 7. So the
|
|
181
217
|
// early refresh is rate-limited by the same TTL as the ordinary one — which is what the
|
|
182
218
|
// docstring above always promised. Set BEFORE the await so concurrent callers that pass the
|
|
183
|
-
// gate together still coalesce into the one
|
|
219
|
+
// gate together still coalesce into the one shared refresh.
|
|
184
220
|
const earlyRefresh = !known && !stale && nowMs >= lastMissRefreshMs + ttlMs;
|
|
185
221
|
if (earlyRefresh) lastMissRefreshMs = nowMs;
|
|
186
222
|
if (stale || earlyRefresh) current = await load();
|
package/src/kdf-gate.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// what argon2id costs is MEMORY — 19 MiB per hash at the OWASP floor. The only remaining backstop
|
|
7
7
|
// was `http.maxInflight` (1000), i.e. roughly 19 GB of arenas queued on one box.
|
|
8
8
|
|
|
9
|
+
import { createFlightGate } from '@ultimat3/core';
|
|
9
10
|
import { kdfOverloaded } from './errors';
|
|
10
11
|
|
|
11
12
|
export interface KdfLimits {
|
|
@@ -27,42 +28,24 @@ export interface KdfGate {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* The mechanism is `@ultimat3/core`'s — including the rule that made this file worth having: a
|
|
32
|
+
* slot is HANDED OVER on release rather than released and re-acquired, because decrementing first
|
|
33
|
+
* would let a caller arriving in the same tick past the ceiling while a waiter's continuation is
|
|
34
|
+
* still a queued microtask.
|
|
35
|
+
*
|
|
36
|
+
* `overflow:` is why delegating costs this package nothing: the shed stays `kdfOverloaded`, so the
|
|
37
|
+
* code an HTTP client already reads as a 503 is still `X_OVERLOADED` and core's own
|
|
38
|
+
* `X_FLIGHT_GATE_OVERLOADED` never leaves this package. `subject:` is deliberately not passed —
|
|
39
|
+
* it feeds only `gateOverloaded`'s prose, which this gate never reaches, and an option nothing
|
|
40
|
+
* reads is the defect this repo keeps re-shipping.
|
|
41
|
+
*
|
|
42
|
+
* The declared return type stays `KdfGate` rather than core's `FlightGate`: `active` and `queued`
|
|
43
|
+
* are observations no caller here has ever had, and widening a public signature is not a refactor.
|
|
34
44
|
*/
|
|
35
45
|
export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const acquire = async (): Promise<void> => {
|
|
40
|
-
if (active < limits.maxConcurrent) {
|
|
41
|
-
active += 1;
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
if (waiters.length >= limits.maxQueued) throw kdfOverloaded(active, waiters.length);
|
|
45
|
-
await new Promise<void>((resolve) => {
|
|
46
|
-
waiters.push(resolve);
|
|
47
|
-
});
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const release = (): void => {
|
|
51
|
-
const next = waiters.shift();
|
|
52
|
-
if (next === undefined) active -= 1;
|
|
53
|
-
else next();
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
async run<T>(work: () => Promise<T>): Promise<T> {
|
|
58
|
-
await acquire();
|
|
59
|
-
try {
|
|
60
|
-
return await work();
|
|
61
|
-
} finally {
|
|
62
|
-
release();
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
};
|
|
46
|
+
return createFlightGate(limits, {
|
|
47
|
+
overflow: (state) => kdfOverloaded(state.active, state.queued),
|
|
48
|
+
});
|
|
66
49
|
}
|
|
67
50
|
|
|
68
51
|
let gate = createKdfGate();
|