@ultimat3/auth
Advanced tools
+24
-4
@@ -262,2 +262,17 @@ # @ultimat3/auth — agent notes | ||
| value. A caller that supplies options gets its own client. | ||
| - **A wedged JWKS refresh no longer holds the shared slot for ever** (`As of 2026-08-23`). | ||
| `createJwksClient` single-flights its refresh through `@ultimat3/core`'s `createSingleFlight`, | ||
| with `deadlineMs = timeoutMs * 2` — derived, never a second invented number: `timeoutMs` bounds | ||
| the network leg (`AbortSignal.timeout`) and everything after it is local work on the same budget, | ||
| so doubling never evicts a slow-but-healthy refresh. It matters because `AbortSignal.timeout` | ||
| bounds only the DEFAULT transport and `options.fetch` is the app's — a `fetch` that ignores its | ||
| signal used to pin the slot for the life of the process, and every later `keyFor` joined a | ||
| promise nothing would resolve. **Eviction frees the KEY, never the work**: the wedged refresh | ||
| runs on and its own callers keep their promise, so the worst case is one duplicate JWKS fetch and | ||
| never a failed verification. `schedule` is injectable so no test waits a deadline out. | ||
| Its consequence is the second half and is not optional: two refreshes can now overlap and both | ||
| end by installing what they read, so a `createFence` generation check is what stops the one the | ||
| client gave up on from dropping a pre-rotation key set on top of the live one. Read | ||
| (`fence.generation() === issued`), never `guard` — a superseded refresh is still the honest | ||
| answer for the callers holding it, and only the shared cache is fenced. | ||
| - **`verifyIdToken` checks `nbf` and `azp`.** `workload.ts` imports `ID_TOKEN_CLOCK_SKEW_MS` from | ||
@@ -305,4 +320,9 @@ `id-token.ts` and then enforced a bound `id-token.ts` did not: an `nbf` ten years out verified. | ||
| listed in `AUTH_BORROWED_ERROR_CODES` — this package cannot import http, and a shed is a shed | ||
| whichever layer performs it. `configureKdfGate()` is the ONE install point and is deliberately | ||
| not a `defineAuth` key: the ceiling is a property of the machine, not of the app's auth policy. | ||
| whichever layer performs it. **The pool itself is `@ultimat3/core`'s `createFlightGate` since | ||
| 2026-08-23** — the same hand-over-on-release rule, the same numbers — and the refusal stays this | ||
| package's through core's `overflow:` seam, so `kdfOverloaded` is still what a caller catches and | ||
| core's `X_FLIGHT_GATE_OVERLOADED` never leaves auth. No `subject:` is passed: it feeds only | ||
| `gateOverloaded`'s prose, which this gate never reaches. `configureKdfGate()` is the ONE install | ||
| point and is deliberately not a `defineAuth` key: the ceiling is a property of the machine, not | ||
| of the app's auth policy. | ||
| - **MFA has a first leg and no second one, and the second one is not a route you can just add.** | ||
@@ -390,3 +410,3 @@ `login()` and `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists; nothing is | ||
| | `oauth-discovery.ts` | `/.well-known/openid-configuration` → an `OAuthProvider`. One `fetch` | | ||
| | `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`. No dependency | | ||
| | `jwks.ts` | `crypto.subtle` signature verification, cached by `kid`, one shared in-flight refresh. No dependency | | ||
| | `workload.ts` | a workload JWT (K8s SA / SPIFFE / IMDS / RFC 8693) → a `ServiceIdentity` | | ||
@@ -407,3 +427,3 @@ | `revocation.ts` | per-user, per-org and before-an-instant sweeps; `disableUser` | | ||
| | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back | | ||
| | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it | | ||
| | `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 | | ||
| | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key | | ||
@@ -410,0 +430,0 @@ | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` | |
+4
-4
| { | ||
| "name": "@ultimat3/auth", | ||
| "version": "11.1.0", | ||
| "version": "11.2.0", | ||
| "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor", | ||
@@ -34,6 +34,6 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "11.1.0", | ||
| "@ultimat3/db": "11.1.0", | ||
| "@ultimat3/schema": "11.1.0" | ||
| "@ultimat3/core": "11.2.0", | ||
| "@ultimat3/db": "11.2.0", | ||
| "@ultimat3/schema": "11.2.0" | ||
| } | ||
| } |
+49
-13
@@ -9,4 +9,4 @@ // Single responsibility: verifying a JWT's signature against a provider's published JWKS. | ||
| import type { Clock } from '@ultimat3/core'; | ||
| import { renderThrowable, systemClock } from '@ultimat3/core'; | ||
| import type { Clock, Scheduler } from '@ultimat3/core'; | ||
| import { createFence, createSingleFlight, renderThrowable, systemClock } from '@ultimat3/core'; | ||
| import { decodeJwtSegment, isRecord } from './json'; | ||
@@ -47,4 +47,20 @@ import type { OAuthProvider } from './oauth'; | ||
| /** One client is one key set, so the flight has exactly one key and its name is never read. */ | ||
| const REFRESH_KEY = 'jwks'; | ||
| const DEFAULT_TIMEOUT_MS = 10_000; | ||
| /** | ||
| * How much longer than the TRANSPORT's own bound one refresh may hold the shared slot, before a | ||
| * later caller is allowed to start its own instead of joining it. | ||
| * | ||
| * Derived from `timeoutMs` rather than a second unrelated number, because the two answer the same | ||
| * question at two layers: `timeoutMs` is the network leg (`AbortSignal.timeout` below), and | ||
| * everything after it — reading the body, importing one `CryptoKey` per published JWK — is local | ||
| * work on the same budget. Doubling gives that second half as much wall clock as the first, so a | ||
| * slow-but-healthy refresh is never evicted, while a `fetch` that IGNORES its signal (which | ||
| * `options.fetch` is app-supplied and free to do) is let go of at twice its own stated bound. | ||
| */ | ||
| const JWKS_DEADLINE_FACTOR = 2; | ||
| export interface JwksClientOptions { | ||
@@ -60,2 +76,4 @@ /** Named in every refusal this client throws. */ | ||
| readonly timeoutMs?: number | undefined; | ||
| /** Injected so the refresh deadline is provable without a test waiting one out. */ | ||
| readonly schedule?: Scheduler | undefined; | ||
| } | ||
@@ -100,2 +118,3 @@ | ||
| const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS; | ||
| const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| let keys = new Map<string, CryptoKey>(); | ||
@@ -107,5 +126,11 @@ let fetchedAtMs = Number.NEGATIVE_INFINITY; | ||
| let lastMissRefreshMs = Number.NEGATIVE_INFINITY; | ||
| let inflight: Promise<Map<string, CryptoKey>> | null = null; | ||
| // Two refreshes can overlap once the deadline below can evict one, and BOTH end by installing | ||
| // what they read. Without this, the later-settling of the two wins — so a refresh the client | ||
| // already gave up on could drop a pre-rotation key set on top of the live one, and every login | ||
| // against the new `kid` would start missing again. Read, never `guard`: a superseded refresh is | ||
| // still the honest answer for the callers holding it, and only the shared cache is fenced. | ||
| const fence = createFence('the published jwks key set'); | ||
| const fetchKeys = async (): Promise<Map<string, CryptoKey>> => { | ||
| const issued = fence.bump(); | ||
| const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); | ||
@@ -117,3 +142,3 @@ let response: Response; | ||
| headers: { Accept: 'application/json' }, | ||
| signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS), | ||
| signal: AbortSignal.timeout(timeoutMs), | ||
| }); | ||
@@ -154,4 +179,6 @@ } catch (error) { | ||
| } | ||
| keys = next; | ||
| fetchedAtMs = clock.now().getTime(); | ||
| if (fence.generation() === issued) { | ||
| keys = next; | ||
| fetchedAtMs = clock.now().getTime(); | ||
| } | ||
| return next; | ||
@@ -162,9 +189,18 @@ }; | ||
| // one outbound request per request in flight, against the IdP, at exactly the worst moment. | ||
| const load = async (): Promise<Map<string, CryptoKey>> => { | ||
| inflight ??= fetchKeys().finally(() => { | ||
| inflight = null; | ||
| }); | ||
| return await inflight; | ||
| }; | ||
| // | ||
| // The deadline is why this is `@ultimat3/core`'s and not a local `inflight ??=`. A refresh that | ||
| // never settles used to hold the slot for the life of the process, and every later caller joined | ||
| // a promise nothing would resolve — `AbortSignal.timeout` bounds only the DEFAULT transport, and | ||
| // `options.fetch` is the app's. Eviction frees the KEY and nothing else: the wedged refresh keeps | ||
| // running, its own callers keep their promise, and the fence above stops its late answer from | ||
| // landing in the cache. So the worst case is one duplicate JWKS fetch, never a failed | ||
| // verification — which is what makes a deadline here a fix rather than a risk. | ||
| const flight = createSingleFlight({ | ||
| deadlineMs: timeoutMs * JWKS_DEADLINE_FACTOR, | ||
| schedule: options.schedule, | ||
| }); | ||
| const load = async (): Promise<Map<string, CryptoKey>> => | ||
| await flight.run(REFRESH_KEY, fetchKeys); | ||
| const lookup = (current: Map<string, CryptoKey>, kid: string | null, alg: JwtAlgorithm) => { | ||
@@ -191,3 +227,3 @@ if (kid !== null) return current.get(`${kid}:${alg}`) ?? null; | ||
| // docstring above always promised. Set BEFORE the await so concurrent callers that pass the | ||
| // gate together still coalesce into the one `inflight` fetch. | ||
| // gate together still coalesce into the one shared refresh. | ||
| const earlyRefresh = !known && !stale && nowMs >= lastMissRefreshMs + ttlMs; | ||
@@ -194,0 +230,0 @@ if (earlyRefresh) lastMissRefreshMs = nowMs; |
+17
-34
@@ -9,2 +9,3 @@ // Single responsibility: bounding how many argon2 hashes this process runs at once. | ||
| import { createFlightGate } from '@ultimat3/core'; | ||
| import { kdfOverloaded } from './errors'; | ||
@@ -31,38 +32,20 @@ | ||
| /** | ||
| * A slot is HANDED OVER on release rather than released and re-acquired: decrementing first would | ||
| * let a caller arriving in the same tick past the ceiling while a waiter's continuation is still | ||
| * a queued microtask, which is how a "bounded" pool goes over its bound under exactly the load it | ||
| * exists for. | ||
| * The mechanism is `@ultimat3/core`'s — including the rule that made this file worth having: a | ||
| * slot is HANDED OVER on release rather than released and re-acquired, because decrementing first | ||
| * would let a caller arriving in the same tick past the ceiling while a waiter's continuation is | ||
| * still a queued microtask. | ||
| * | ||
| * `overflow:` is why delegating costs this package nothing: the shed stays `kdfOverloaded`, so the | ||
| * code an HTTP client already reads as a 503 is still `X_OVERLOADED` and core's own | ||
| * `X_FLIGHT_GATE_OVERLOADED` never leaves this package. `subject:` is deliberately not passed — | ||
| * it feeds only `gateOverloaded`'s prose, which this gate never reaches, and an option nothing | ||
| * reads is the defect this repo keeps re-shipping. | ||
| * | ||
| * The declared return type stays `KdfGate` rather than core's `FlightGate`: `active` and `queued` | ||
| * are observations no caller here has ever had, and widening a public signature is not a refactor. | ||
| */ | ||
| export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate { | ||
| let active = 0; | ||
| const waiters: Array<() => void> = []; | ||
| const acquire = async (): Promise<void> => { | ||
| if (active < limits.maxConcurrent) { | ||
| active += 1; | ||
| return; | ||
| } | ||
| if (waiters.length >= limits.maxQueued) throw kdfOverloaded(active, waiters.length); | ||
| await new Promise<void>((resolve) => { | ||
| waiters.push(resolve); | ||
| }); | ||
| }; | ||
| const release = (): void => { | ||
| const next = waiters.shift(); | ||
| if (next === undefined) active -= 1; | ||
| else next(); | ||
| }; | ||
| return { | ||
| async run<T>(work: () => Promise<T>): Promise<T> { | ||
| await acquire(); | ||
| try { | ||
| return await work(); | ||
| } finally { | ||
| release(); | ||
| } | ||
| }, | ||
| }; | ||
| return createFlightGate(limits, { | ||
| overflow: (state) => kdfOverloaded(state.active, state.queued), | ||
| }); | ||
| } | ||
@@ -69,0 +52,0 @@ |
387495
1.17%6483
0.29%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
Updated
Updated
Updated