New:Socket for Asana Is Now Available.Learn more
Sign In

@ultimat3/auth

Package Overview
Dependencies
Maintainers
1
Versions
25
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/auth - npm Package Compare versions

Comparing version
11.1.0
to
11.2.0
+24
-4
CLAUDE.md

@@ -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"
}
}

@@ -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;

@@ -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 @@