@ultimat3/auth 16.0.0 → 17.0.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 CHANGED
@@ -16,6 +16,60 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
16
16
 
17
17
  ## Non-negotiables
18
18
 
19
+ - **A policy NUMBER is screened at `defineAuth`, `As of 2026-08-26`** (`policy-numbers.ts`,
20
+ `X_CONFIG_INVALID`). `session.absoluteTtlMs`, `session.idleTtlMs`, `session.idleSlideMs`,
21
+ `password.minLength`, the two argon2 costs and every `rateLimit` number. Measured with `NaN` —
22
+ what `Number(process.env.SESSION_TTL_MS)` answers when the variable is unset, and not nullish, so
23
+ the spread over the defaults keeps it: `now >= NaN` is false, so a session idle since 2000
24
+ reported `absoluteExpired: false` AND `idleExpired: false`; `password.length < NaN` is false and
25
+ the two-distinct-characters rule is guarded by `length > 0`, so the EMPTY password was accepted.
26
+ A rule whose comparison is false for every input is not a loose rule, it is no rule.
27
+
28
+ - **Every RUNTIME number this package bounds anything with is screened too, `As of 2026-08-26`** —
29
+ `assertFiniteAuthCount` in `policy-numbers.ts`, one file for both halves. `jwks.ttlMs`
30
+ (`now >= fetchedAt + NaN` is false, so a provider's key rotation is never refetched and every
31
+ login against the new `kid` fails until the process restarts), the three OAuth legs' `timeoutMs`
32
+ (`AbortSignal.timeout(NaN)` THROWS, and it is screened OUTSIDE each leg's `try` — inside it, the
33
+ catch rendered an app config typo as the identity provider being unreachable), the limiter's
34
+ `maxKeys` (`Math.max(1, Math.floor(NaN))` is `NaN`, so the sweep bounding a table half of whose
35
+ keys are attacker-chosen never ran) and `mfa.drift`. **`drift` is the one that hangs**: it is a
36
+ LOOP BOUND, not a comparison — `for (offset = -Infinity; offset <= Infinity; offset += 1)` never
37
+ terminates, measured, synchronously, on the login path — and `NaN` makes the loop never run at
38
+ all, so every correct code is rejected as if it were wrong. Editing `verifyTotp` with the screen
39
+ removed WEDGES `mfa.test.ts` rather than failing it; mutate with the `NaN` case.
40
+
41
+ **That sentence was false when it was written and four more sites closed it, `As of 2026-08-26`.**
42
+ It named the options `defineAuth` resolves and the ones a *service* call takes, and missed every
43
+ number a **cookie** or a **mailed link** is bounded by. `oauth.handshake.ttlMs` is the one that
44
+ matters: `openHandshake`'s `now - issuedAt > NaN` is false, so a year-old sealed handshake — the
45
+ state, nonce and PKCE verifier that ARE the callback leg's CSRF defence — opened and returned its
46
+ verifier, while `handshakeCookie` wrote `Max-Age=NaN`, which is not `delta-seconds`, so the
47
+ browser dropped the attribute and kept the cookie for the whole session. One number, both ends of
48
+ the deadline, off together and silently. `session.cookie.maxAgeSeconds` is the same shape beside
49
+ a `policy.absoluteTtlMs` that WAS screened; `kdf.maxConcurrent`/`kdf.maxQueued` are the second
50
+ thing that WEDGES after `mfa.drift` — core asks `active < maxConcurrent` then
51
+ `waiters.length >= maxQueued`, both false for `NaN`, so every `hashPassword` on the box parks in
52
+ an unbounded queue nothing releases and login stops answering rather than shedding.
53
+
54
+ - **A number is screened ABOVE the write it feeds, not beside the arithmetic** (`As of
55
+ 2026-08-26`). `issueVerification` read `input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[purpose]`
56
+ straight into `new Date(now + ttl)` and threw a bare `RangeError` — `toISOString()` refuses an
57
+ Invalid Date — on the line AFTER `putVerification` had already resolved. Two faults from one
58
+ number: an uncoded throw out of the package, and a durable row whose expiry `consumeVerification`
59
+ compares as `now >= NaN`, false for ever, i.e. a password-reset link that never expires. The
60
+ write also upserts on `(purpose, identifier)`, so the failing call had destroyed whatever live
61
+ token that address held on its way out. `verify.test.ts` asserts the ordering directly (no row
62
+ written, no mail sent, the earlier token still redeemable) — moving the screen below the store
63
+ call fails those three and leaves the coded-refusal case green.
64
+
65
+ - **The minimum is per option, and zero is usually legitimate.** `assertFiniteAuthCount`'s `min` is
66
+ `0 | 1` because only the call site knows what zero MEANS, and picking `1` for tidiness is how a
67
+ screen breaks a working deployment while every test still passes. `maxAgeSeconds: 0` is how a
68
+ cookie is expired — it is what sign-out emits — and `{ maxConcurrent: 0, maxQueued: 0 }` is a
69
+ gate that refuses every hash, which is how `password.test.ts` proves the unreadable-hash path
70
+ burns the same KDF a wrong password does. Both take `min: 0`. A TTL takes `min: 1`, because a
71
+ handshake or a mailed link that is already expired when it is issued is not a configuration.
72
+
19
73
  - Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
20
74
  parameter to it re-opens account enumeration.
21
75
  - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF** (`As of
@@ -394,7 +448,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
394
448
 
395
449
  | File | Job |
396
450
  |---|---|
397
- | `auth.ts` | `defineAuth`, entity schemas, `login`/`register`/`authenticate`/`logout` |
451
+ | `auth.ts` | `defineAuth`, entity schemas, `login`/`register`/`authenticate`/`logout`. Three suites, split at the line ceiling along the three subjects: `auth.test.ts` (the credential flow), `auth-config.test.ts` (`defineAuth`'s defaults and refusals) and `auth-lockout.test.ts` (the tenant and address buckets) |
398
452
  | `policy-bridge.ts` | the one funnel: identity → `Actor`, all four `ActorKind`s |
399
453
  | `session.ts` | two expiries, rotation, revocation, device list, the cookie |
400
454
  | `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
@@ -414,6 +468,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
414
468
  | `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
415
469
  | `id-token.ts` | id token → claims this handshake may believe |
416
470
  | `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
471
+ | `auth-fixture.ts` | the KDF parameters, the password and the `AuthError` catcher the three `auth*.test.ts` suites share. Off `index.ts` |
417
472
  | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile` |
418
473
  | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
419
474
  | `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "16.0.0",
3
+ "version": "17.0.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": "16.0.0",
35
- "@ultimat3/db": "16.0.0",
36
- "@ultimat3/schema": "16.0.0"
34
+ "@ultimat3/core": "17.0.0",
35
+ "@ultimat3/db": "17.0.0",
36
+ "@ultimat3/schema": "17.0.0"
37
37
  }
38
38
  }
@@ -0,0 +1,26 @@
1
+ // Single responsibility: the three values every credential-flow suite needs — cheap KDF
2
+ // parameters, the password they all sign in with, and the `AuthError` catcher. Three test files
3
+ // share them, and three private copies is three chances for one to drift from what the flow
4
+ // actually enforces. Not part of the public API — `index.ts` deliberately does not re-export it.
5
+
6
+ import { AuthError } from './errors';
7
+ import type { PasswordParams } from './password';
8
+
9
+ // Fast KDF parameters: these tests are about the credential flow, not argon2's cost.
10
+ export const FAST_PARAMS: PasswordParams = { algorithm: 'argon2id', memoryCost: 8192, timeCost: 1 };
11
+
12
+ export const PASSWORD = 'correct-horse-battery-staple-42';
13
+
14
+ /** Captures the thrown `AuthError`, or `undefined` when the call unexpectedly resolved. The
15
+ * caller's `expect(error?.code).toBe(...)` is then the assertion that fails, naming the code it
16
+ * wanted — a sentinel thrown from in here would carry no code and no fix. Anything that is not
17
+ * an `AuthError` is rethrown untouched: this helper never swallows an unexpected failure. */
18
+ export const caught = async (fn: () => Promise<unknown>): Promise<AuthError | undefined> => {
19
+ try {
20
+ await fn();
21
+ return undefined;
22
+ } catch (error) {
23
+ if (error instanceof AuthError) return error;
24
+ throw error;
25
+ }
26
+ };
package/src/auth.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  verifyPassword,
19
19
  } from './password';
20
20
  import { type PolicyActor, resolveActor } from './policy-bridge';
21
+ import { assertPasswordPolicy, assertRateLimitPolicy, assertSessionPolicy } from './policy-numbers';
21
22
  import {
22
23
  type AuthLimiter,
23
24
  type AuthRateLimitPolicy,
@@ -176,6 +177,12 @@ export function defineAuth(config: AuthConfigInput): Auth {
176
177
  const session: SessionPolicy = { ...DEFAULT_SESSION_POLICY, ...config.session };
177
178
  const password: PasswordPolicy = { ...DEFAULT_PASSWORD_POLICY, ...config.password };
178
179
  const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, ...config.rateLimit };
180
+ // Screened on the MERGED policy, before anything is built from it: an override is spread over
181
+ // defaults the author never restated, so the resolved object is the only one that can be judged
182
+ // — the same reason `defineHttpConfig` judges the merged `cors` rather than the input.
183
+ assertSessionPolicy(session);
184
+ assertPasswordPolicy(password);
185
+ assertRateLimitPolicy(rateLimit);
179
186
  // Three answers in precedence order, and the middle one is why the seam exists: what this call
180
187
  // passed, then what the HOST installed (`configureAuthLimiters`, filled by the boot that owns
181
188
  // the database connection), then one process' worth of state. Without the middle arm a
package/src/errors.ts CHANGED
@@ -216,6 +216,31 @@ export const mfaRequiredUnenforceable = (): AuthError =>
216
216
  fix: 'drop required from defineAuth({ mfa }) and gate it in your own sign-in handler, which is where the enrolment route lives: if (user.mfaSecret === null) send them to enrolTotp(auth, { account: user.email }) instead of createSession(auth.sessions, ...)',
217
217
  });
218
218
 
219
+ /**
220
+ * A policy NUMBER that is not one, refused where `defineAuth` still names the key.
221
+ *
222
+ * Every one of these arrives as `Number(process.env.SESSION_TTL_MS)` as often as a literal, and
223
+ * that is `NaN` for an unset variable — not nullish, so the spread over the defaults keeps it, and
224
+ * then every comparison it reaches is false. What that produces is not a short session or a weak
225
+ * password, it is the RULE not existing: `now >= NaN` is false, so a session never expires on
226
+ * either clock; `password.length < NaN` is false, so the empty password was accepted. Both were
227
+ * measured. `X_CONFIG_INVALID` is core's code, borrowed rather than re-declared, beside
228
+ * `mfaRequiredUnenforceable` above and for the same reason — a declaration this package cannot
229
+ * honour is not a new kind of failure.
230
+ */
231
+ export const authPolicyNumberInvalid = (
232
+ key: string,
233
+ value: number,
234
+ expected: string,
235
+ consequence: string,
236
+ ): AuthError =>
237
+ new AuthError({
238
+ code: 'X_CONFIG_INVALID',
239
+ cause: `defineAuth({ ${key}: ${String(value)} }) is not ${expected}; ${consequence}, so the rule is not enforced at all rather than enforced wrongly — and NaN is what Number(process.env.…) answers for an unset variable`,
240
+ fix: `pass ${expected} for ${key} in defineAuth, and parse an environment value before you pass it — Number.parseInt(process.env.AUTH_TTL_MS ?? '', 10) is NaN when the variable is unset, so give it a default: Number.parseInt(process.env.AUTH_TTL_MS ?? '2592000000', 10)`,
241
+ meta: { option: key, value: String(value) },
242
+ });
243
+
219
244
  export const passwordWeak = (reasons: readonly string[]): AuthError =>
220
245
  new AuthError({
221
246
  code: 'X_PASSWORD_WEAK',
package/src/jwks.ts CHANGED
@@ -12,6 +12,7 @@ import { decodeJwtSegment, isRecord } from './json';
12
12
  import type { OAuthProvider } from './oauth';
13
13
  import { oauthExchangeFailed, oauthTokenInvalid } from './oauth-errors';
14
14
  import type { OAuthFetch } from './oauth-exchange';
15
+ import { assertFiniteAuthCount } from './policy-numbers';
15
16
  import { base64UrlBytes } from './tokens';
16
17
 
17
18
  /**
@@ -112,7 +113,19 @@ const algorithmOf = (jwk: Record<string, unknown>): JwtAlgorithm | null => {
112
113
  export function createJwksClient(options: JwksClientOptions): JwksKeySource {
113
114
  const clock = options.clock ?? systemClock;
114
115
  const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
116
+ assertFiniteAuthCount(
117
+ 'jwks.ttlMs',
118
+ ttlMs,
119
+ '`now >= fetchedAt + NaN` is false, so the key set is never stale and a rotation is never refetched — every login against the new kid fails until the process restarts',
120
+ 1,
121
+ );
115
122
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
123
+ assertFiniteAuthCount(
124
+ 'jwks.timeoutMs',
125
+ timeoutMs,
126
+ 'AbortSignal.timeout throws a bare TypeError on it, several frames below the option that set it, and the single-flight deadline derived from it goes with it',
127
+ 1,
128
+ );
116
129
  let keys = new Map<string, CryptoKey>();
117
130
  let fetchedAtMs = Number.NEGATIVE_INFINITY;
118
131
  // When the last UNKNOWN-`kid` refresh ran, tracked apart from `fetchedAtMs` because that field
package/src/kdf-gate.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { createFlightGate } from '@ultimat3/core';
10
10
  import { kdfOverloaded } from './errors';
11
+ import { assertFiniteAuthCount } from './policy-numbers';
11
12
 
12
13
  export interface KdfLimits {
13
14
  /** Hashes running at once. Multiply by `memoryCost` for the resident ceiling this buys. */
@@ -43,6 +44,28 @@ export interface KdfGate {
43
44
  * are observations no caller here has ever had, and widening a public signature is not a refactor.
44
45
  */
45
46
  export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
47
+ // Screened at the CONSTRUCTOR, because this pair WEDGES rather than fails: core asks
48
+ // `active < maxConcurrent` and then `waiters.length >= maxQueued`, and both are false for `NaN`,
49
+ // so every hash on the box parks in a queue with no bound and nothing to release it — login
50
+ // stops answering instead of shedding.
51
+ //
52
+ // ZERO is legitimate at BOTH, which is why the minimum is 0 and not 1: `maxQueued: 0` means
53
+ // "shed at the width, never wait", and `{ maxConcurrent: 0, maxQueued: 0 }` is a gate that
54
+ // refuses every hash — `password.test.ts`'s way of proving the unreadable-hash path burns the
55
+ // same KDF the other two failures do. Refusing zero here would have failed that test, which is
56
+ // the deployment this screen must not break.
57
+ assertFiniteAuthCount(
58
+ 'kdf.maxConcurrent',
59
+ limits.maxConcurrent,
60
+ '`active < NaN` is false, so no hash ever starts and every caller queues instead',
61
+ 0,
62
+ );
63
+ assertFiniteAuthCount(
64
+ 'kdf.maxQueued',
65
+ limits.maxQueued,
66
+ '`waiters.length >= NaN` is false, so the queue this gate exists to bound has no bound at all',
67
+ 0,
68
+ );
46
69
  return createFlightGate(limits, {
47
70
  overflow: (state) => kdfOverloaded(state.active, state.queued),
48
71
  });
package/src/mfa.ts CHANGED
@@ -3,8 +3,10 @@
3
3
  // phishing page stays valid for the rest of its 30 seconds. Recovery codes are hashed at rest
4
4
  // and single-use, so a database dump is not a permanent MFA bypass.
5
5
 
6
+ import { finiteCount } from '@ultimat3/core';
6
7
  import type { Auth } from './auth';
7
8
  import { mfaSecretInvalid } from './errors';
9
+ import { assertFiniteAuthCount } from './policy-numbers';
8
10
  import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
9
11
 
10
12
  const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
@@ -166,7 +168,17 @@ export function verifyTotp(input: VerifyTotpInput): TotpVerification {
166
168
  // "this account's secret is malformed" to whoever asked. It also keeps `totpCode`'s refusal off
167
169
  // the login path entirely — nothing below can reach it with a zero-length key.
168
170
  if (base32Decode(input.secret).length === 0) return { ok: false, step: null };
171
+ // A LOOP BOUND, screened before it becomes one: measured, `drift: Infinity` never terminates
172
+ // (`-Infinity + 1` is `-Infinity`) — a synchronous infinite loop on the login path, past every
173
+ // AbortSignal — and `drift: NaN` makes `-NaN <= NaN` false, so the loop never runs and every
174
+ // correct code is rejected as if it were wrong. Zero is legitimate: the current step only.
169
175
  const drift = input.drift ?? TOTP_DRIFT_STEPS;
176
+ assertFiniteAuthCount(
177
+ 'mfa.drift',
178
+ drift,
179
+ 'the verification loop either never terminates (Infinity) or never runs at all (NaN), and the second answers "wrong code" to every correct one',
180
+ 0,
181
+ );
170
182
  const current = totpStep(input.at);
171
183
  const candidate = input.code.replaceAll(' ', '');
172
184
  for (let offset = -drift; offset <= drift; offset += 1) {
@@ -296,9 +308,21 @@ export interface RecoveryCodeSet {
296
308
  const normaliseRecoveryCode = (code: string): string =>
297
309
  code.replaceAll('-', '').replaceAll(' ', '').toUpperCase();
298
310
 
311
+ /**
312
+ * `count` is a LOOP BOUND, so it fails in both directions and neither is a smaller set of codes.
313
+ * `NaN` — what `Number(process.env.RECOVERY_CODES)` answers for an unset variable, and not nullish,
314
+ * so the default never sees it — makes `index < count` false at once and enrols a user with ZERO
315
+ * ways back into their account, in a well-formed `{ codes: [], hashes: [] }` that no caller can
316
+ * tell from a real one. `Infinity` is the other end and it WEDGES: measured, the loop never
317
+ * terminates, synchronously, on the enrolment path. Same shape as `mfa.drift`.
318
+ *
319
+ * `finiteCount` from core rather than `assertFiniteAuthCount`, for the reason `randomToken` gives:
320
+ * that refusal spells `defineAuth({ <key>: … })` and this is a call-site argument.
321
+ */
299
322
  export function generateRecoveryCodes(count = 10): RecoveryCodeSet {
323
+ const wanted = finiteCount('generateRecoveryCodes', 'count', count, 1);
300
324
  const codes: string[] = [];
301
- for (let index = 0; index < count; index += 1) {
325
+ for (let index = 0; index < wanted; index += 1) {
302
326
  const raw = base32Encode(randomBytes(10)).slice(0, 16);
303
327
  codes.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}`);
304
328
  }
@@ -10,6 +10,7 @@ import { EnvMissingError, systemClock } from '@ultimat3/core';
10
10
  import type { OAuthHandshake, OAuthProviderId } from './oauth';
11
11
  import { oauthStateInvalid } from './oauth-errors';
12
12
  import { hasOAuthProvider } from './oauth-registry';
13
+ import { assertFiniteAuthCount } from './policy-numbers';
13
14
  import { type RequestLike, readCookie } from './session';
14
15
  import { base64Url, timingSafeEqual } from './tokens';
15
16
 
@@ -30,6 +31,26 @@ export function handshakeCookieName(provider: OAuthProviderId): string {
30
31
  /** Long enough to read a consent screen, short enough that a lifted cookie is already stale. */
31
32
  export const DEFAULT_HANDSHAKE_TTL_MS = 10 * 60 * 1000;
32
33
 
34
+ /**
35
+ * One screen for both ends of the deadline, because it is one number bounding two things: the
36
+ * server comparison in `openHandshake` and the `Max-Age` the browser is asked to honour. `NaN`
37
+ * switches off both at once and neither says anything — `now - issuedAt > NaN` is false forever,
38
+ * and `Max-Age=NaN` is not `delta-seconds`, so a browser drops the attribute and keeps the cookie
39
+ * until the tab closes. What survives is the state, nonce and PKCE verifier that are the CSRF
40
+ * defence of the callback leg, replayable a year later.
41
+ *
42
+ * Screened at every reader rather than once at seal time: the two legs are two HTTP requests and
43
+ * two calls, so a check on the way out is one the way back never runs.
44
+ */
45
+ function handshakeTtlMs(options: HandshakeSealOptions | undefined): number {
46
+ return assertFiniteAuthCount(
47
+ 'oauth.handshake.ttlMs',
48
+ options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS,
49
+ 'the server-side replay deadline is a comparison that is false for every handshake and the cookie carries a Max-Age no browser keeps, so a lifted handshake stays redeemable',
50
+ 1,
51
+ );
52
+ }
53
+
33
54
  /** `wiki/Configuration.md` requires it at >=32 chars for the `web` role; this is that gate. */
34
55
  const MIN_SECRET_LENGTH = 32;
35
56
 
@@ -146,7 +167,7 @@ export function openHandshake(
146
167
  // The cookie's own `Max-Age` is the client's copy of this deadline, and a client is free to
147
168
  // ignore it — so the age that decides is measured here, against the server's clock.
148
169
  const clock = options?.clock ?? systemClock;
149
- if (clock.now().getTime() - issuedAt > (options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS)) {
170
+ if (clock.now().getTime() - issuedAt > handshakeTtlMs(options)) {
150
171
  throw oauthStateInvalid(provider, 'the stored handshake expired before the callback arrived');
151
172
  }
152
173
 
@@ -164,7 +185,7 @@ export function handshakeCookie(
164
185
  handshake: OAuthHandshake,
165
186
  options?: HandshakeCookieOptions,
166
187
  ): string {
167
- const maxAge = Math.floor((options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS) / 1000);
188
+ const maxAge = Math.floor(handshakeTtlMs(options) / 1000);
168
189
  // The handshake already names its provider, so the two legs cannot disagree about the name.
169
190
  const name = options?.name ?? handshakeCookieName(handshake.provider);
170
191
  return `${name}=${sealHandshake(handshake, options)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
@@ -8,6 +8,11 @@ import { isRecord } from './json';
8
8
  import type { OAuthProvider } from './oauth';
9
9
  import { oauthExchangeFailed } from './oauth-errors';
10
10
  import type { OAuthFetch } from './oauth-exchange';
11
+ import { assertFiniteAuthCount } from './policy-numbers';
12
+
13
+ /** Named in the refusal above the one `fetch` this file makes. */
14
+ const TIMEOUT_CONSEQUENCE =
15
+ 'AbortSignal.timeout throws a bare TypeError on it, several frames below the option that set it, so the leg fails with an error that names neither this package nor the option';
11
16
 
12
17
  const DEFAULT_TIMEOUT_MS = 10_000;
13
18
 
@@ -50,13 +55,22 @@ export async function discoverOAuthProvider(
50
55
  ): Promise<OAuthProvider> {
51
56
  const url = discoveryUrl(input.issuer);
52
57
  const doFetch: OAuthFetch = input.fetch ?? ((target, init) => globalThis.fetch(target, init));
58
+ // Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
59
+ // catch around this fetch renders every throw as a provider failure — so a typo in the app's
60
+ // own config read as the identity provider being unreachable.
61
+ const timeoutMs = assertFiniteAuthCount(
62
+ 'oauth.discovery.timeoutMs',
63
+ input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
64
+ TIMEOUT_CONSEQUENCE,
65
+ 1,
66
+ );
53
67
 
54
68
  let response: Response;
55
69
  try {
56
70
  response = await doFetch(url, {
57
71
  method: 'GET',
58
72
  headers: { Accept: 'application/json' },
59
- signal: AbortSignal.timeout(input.timeoutMs ?? DEFAULT_TIMEOUT_MS),
73
+ signal: AbortSignal.timeout(timeoutMs),
60
74
  });
61
75
  } catch (error) {
62
76
  throw oauthExchangeFailed({
@@ -22,6 +22,7 @@ import {
22
22
  } from './oauth';
23
23
  import { oauthExchangeFailed, restartAt } from './oauth-errors';
24
24
  import { providerFor } from './oauth-registry';
25
+ import { assertFiniteAuthCount } from './policy-numbers';
25
26
 
26
27
  /** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
27
28
  export type OAuthFetch = (input: string, init: RequestInit) => Promise<Response>;
@@ -29,6 +30,10 @@ export type OAuthFetch = (input: string, init: RequestInit) => Promise<Response>
29
30
  /** GitHub answers 403 to a request with no user agent, so every call this package makes has one. */
30
31
  export const OAUTH_USER_AGENT = 'ultimate-auth';
31
32
 
33
+ /** Named in the refusal above the one `fetch` this file makes. */
34
+ const TIMEOUT_CONSEQUENCE =
35
+ 'AbortSignal.timeout throws a bare TypeError on it, several frames below the option that set it, so the leg fails with an error that names neither this package nor the option';
36
+
32
37
  const DEFAULT_TIMEOUT_MS = 10_000;
33
38
  const MAX_DETAIL_LENGTH = 200;
34
39
 
@@ -158,6 +163,15 @@ async function postForm(
158
163
  options: OAuthExchangeOptions,
159
164
  ): Promise<Record<string, unknown>> {
160
165
  const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
166
+ // Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
167
+ // catch around this fetch renders every throw as a provider failure — so a typo in the app's
168
+ // own config read as the identity provider being unreachable.
169
+ const timeoutMs = assertFiniteAuthCount(
170
+ 'oauth.exchange.timeoutMs',
171
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
172
+ TIMEOUT_CONSEQUENCE,
173
+ 1,
174
+ );
161
175
  const url = providerFor(provider).tokenUrl;
162
176
 
163
177
  let response: Response;
@@ -171,7 +185,7 @@ async function postForm(
171
185
  'User-Agent': OAUTH_USER_AGENT,
172
186
  },
173
187
  body: body.toString(),
174
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
188
+ signal: AbortSignal.timeout(timeoutMs),
175
189
  });
176
190
  } catch (error) {
177
191
  // The rendered value goes to the LOG, never into the `detail:`.
@@ -15,6 +15,11 @@ import {
15
15
  providerDetail,
16
16
  } from './oauth-exchange';
17
17
  import { providerFor } from './oauth-registry';
18
+ import { assertFiniteAuthCount } from './policy-numbers';
19
+
20
+ /** Named in the refusal above the one `fetch` this file makes. */
21
+ const TIMEOUT_CONSEQUENCE =
22
+ 'AbortSignal.timeout throws a bare TypeError on it, several frames below the option that set it, so the leg fails with an error that names neither this package nor the option';
18
23
 
19
24
  const DEFAULT_TIMEOUT_MS = 10_000;
20
25
 
@@ -49,6 +54,15 @@ async function getJson(
49
54
  options: OAuthProfileOptions,
50
55
  ): Promise<{ ok: true; body: unknown } | { ok: false; status: number; detail: string }> {
51
56
  const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
57
+ // Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
58
+ // catch around this fetch renders every throw as a provider failure — so a typo in the app's
59
+ // own config read as the identity provider being unreachable.
60
+ const timeoutMs = assertFiniteAuthCount(
61
+ 'oauth.profile.timeoutMs',
62
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
63
+ TIMEOUT_CONSEQUENCE,
64
+ 1,
65
+ );
52
66
  let response: Response;
53
67
  try {
54
68
  response = await doFetch(url, {
@@ -58,7 +72,7 @@ async function getJson(
58
72
  Authorization: `Bearer ${accessToken}`,
59
73
  'User-Agent': OAUTH_USER_AGENT,
60
74
  },
61
- signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
75
+ signal: AbortSignal.timeout(timeoutMs),
62
76
  });
63
77
  } catch (error) {
64
78
  // `renderThrowable`, never `error.message` behind an `instanceof`: `fetch` is INJECTED here,
@@ -0,0 +1,124 @@
1
+ // Single responsibility: the numeric screen over every option this package bounds anything with —
2
+ // the three policies `defineAuth` resolves, and the runtime options (`jwks`, the OAuth legs, TOTP
3
+ // drift, the limiter's key cap) that arrive on a call rather than through config. One file, because
4
+ // the rule is one rule: a duration, a length and an allowance are all "a whole positive number, and
5
+ // NaN is what an unset environment variable parses to".
6
+
7
+ import { authPolicyNumberInvalid } from './errors';
8
+ import type { PasswordPolicy } from './password';
9
+ import type { AuthRateLimitPolicy } from './rate-limit';
10
+ import type { SessionPolicy } from './session';
11
+
12
+ const WHOLE_POSITIVE = 'a whole number greater than zero';
13
+ const WHOLE_NON_NEGATIVE = 'a whole number of zero or more';
14
+
15
+ /**
16
+ * `Number.isSafeInteger`, not `Number.isFinite`: these are millisecond durations and attempt
17
+ * counts, and past 2^53 a double cannot name its own successor — a "duration" up there is already
18
+ * a rounded one, and `now + it` no longer moves.
19
+ *
20
+ * The `Finite` in the name is load-bearing: `bun run finite-bounds` recognises a repair by the
21
+ * shape of the CALL, so a screen named `whole` left every call site reading as unchecked.
22
+ */
23
+ export function assertFiniteAuthCount(
24
+ key: string,
25
+ value: number,
26
+ consequence: string,
27
+ min: 0 | 1,
28
+ ): number {
29
+ if (Number.isSafeInteger(value) && value >= min) return value;
30
+ throw authPolicyNumberInvalid(
31
+ key,
32
+ value,
33
+ min === 1 ? WHOLE_POSITIVE : WHOLE_NON_NEGATIVE,
34
+ consequence,
35
+ );
36
+ }
37
+
38
+ /** Both clocks and the slide between them. A session that never expires is the failure here. */
39
+ export function assertSessionPolicy(policy: SessionPolicy): void {
40
+ assertFiniteAuthCount(
41
+ 'session.absoluteTtlMs',
42
+ policy.absoluteTtlMs,
43
+ 'the absolute expiry becomes an Invalid Date and `now >= NaN` is false, so the session never expires on that clock',
44
+ 1,
45
+ );
46
+ assertFiniteAuthCount(
47
+ 'session.idleTtlMs',
48
+ policy.idleTtlMs,
49
+ '`now - lastSeenAt >= NaN` is false, so a session idle for years still reports itself live',
50
+ 1,
51
+ );
52
+ if (policy.idleSlideMs !== undefined) {
53
+ assertFiniteAuthCount(
54
+ 'session.idleSlideMs',
55
+ policy.idleSlideMs,
56
+ 'the write that moves `lastSeenAt` forward is skipped or taken on every single request',
57
+ 0,
58
+ );
59
+ }
60
+ }
61
+
62
+ /** The length rule, and the two KDF costs that decide what a stolen hash is worth. */
63
+ export function assertPasswordPolicy(policy: PasswordPolicy): void {
64
+ assertFiniteAuthCount(
65
+ 'password.minLength',
66
+ policy.minLength,
67
+ '`password.length < NaN` is false for every password, and the two-distinct-characters rule is guarded by `length > 0`, so the EMPTY password is accepted',
68
+ 1,
69
+ );
70
+ assertFiniteAuthCount(
71
+ 'password.params.memoryCost',
72
+ policy.params.memoryCost,
73
+ 'argon2 refuses it several frames below the config line that set it, on the first registration',
74
+ 1,
75
+ );
76
+ assertFiniteAuthCount(
77
+ 'password.params.timeCost',
78
+ policy.params.timeCost,
79
+ 'argon2 refuses it several frames below the config line that set it, on the first registration',
80
+ 1,
81
+ );
82
+ }
83
+
84
+ /**
85
+ * The lockout numbers. These were already refused — by `assertAuthLimiterPolicy`, whose `NaN ===
86
+ * NaN` comparison fails — but as `X_AUTH_LIMITER_POLICY_MISMATCH`, which tells an operator the
87
+ * limiter enforces different numbers than the app declared. It does not: the number is a typo.
88
+ */
89
+ export function assertRateLimitPolicy(policy: AuthRateLimitPolicy): void {
90
+ assertFiniteAuthCount(
91
+ 'rateLimit.maxAttempts',
92
+ policy.maxAttempts,
93
+ '`failures.length >= NaN` is false, so the lockout never engages and guessing is unlimited',
94
+ 1,
95
+ );
96
+ assertFiniteAuthCount(
97
+ 'rateLimit.windowMs',
98
+ policy.windowMs,
99
+ 'every recorded failure falls outside the window immediately, so nothing ever accumulates',
100
+ 1,
101
+ );
102
+ assertFiniteAuthCount(
103
+ 'rateLimit.lockoutMs',
104
+ policy.lockoutMs,
105
+ '`now < lockedUntil` is false, so a lockout that was established holds nobody',
106
+ 1,
107
+ );
108
+ if (policy.orgMaxAttempts !== undefined) {
109
+ assertFiniteAuthCount(
110
+ 'rateLimit.orgMaxAttempts',
111
+ policy.orgMaxAttempts,
112
+ 'the tenant bucket never fills, so one org can saturate the shared limiter',
113
+ 1,
114
+ );
115
+ }
116
+ if (policy.maxKeys !== undefined) {
117
+ assertFiniteAuthCount(
118
+ 'rateLimit.maxKeys',
119
+ policy.maxKeys,
120
+ 'the in-memory table has no ceiling, so a spray of distinct keys is memory the attacker chooses',
121
+ 1,
122
+ );
123
+ }
124
+ }
package/src/rate-limit.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  authLimiterNotShared,
12
12
  authLimiterPolicyMismatch,
13
13
  } from './errors';
14
+ import { assertFiniteAuthCount } from './policy-numbers';
14
15
 
15
16
  /**
16
17
  * Where a limiter's counters live. A limiter says which it provides; `AuthRateLimitPolicy` says
@@ -157,7 +158,15 @@ export function createAuthLimiter(
157
158
  policy: AuthRateLimitPolicy = DEFAULT_AUTH_RATE_LIMIT,
158
159
  ): MemoryAuthLimiter {
159
160
  const buckets = new Map<string, Bucket>();
160
- const maxKeys = Math.max(1, Math.floor(policy.maxKeys ?? DEFAULT_MAX_AUTH_LIMIT_KEYS));
161
+ // Screened, not clamped: `Math.floor(NaN)` is `NaN`, so `buckets.size > maxKeys` is false and
162
+ // the sweep that bounds this table never runs. Half these keys are attacker-chosen (`ipKey`),
163
+ // which is what makes the ceiling the only thing between a spray and this process's memory.
164
+ const maxKeys = assertFiniteAuthCount(
165
+ 'rateLimit.maxKeys',
166
+ policy.maxKeys ?? DEFAULT_MAX_AUTH_LIMIT_KEYS,
167
+ 'the in-memory table has no ceiling, so a spray of distinct keys is memory the attacker chooses',
168
+ 1,
169
+ );
161
170
  const evictTo = Math.max(1, Math.floor(maxKeys * 0.9));
162
171
  let lastSweepMs = Number.NEGATIVE_INFINITY;
163
172
 
package/src/session.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import type { Clock } from '@ultimat3/core';
8
8
  import type { AuthSession, SessionStore } from './adapter';
9
9
  import { sessionExpired, sessionUnknown } from './errors';
10
+ import { assertFiniteAuthCount } from './policy-numbers';
10
11
  import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
11
12
 
12
13
  export interface SessionPolicy {
@@ -230,6 +231,13 @@ export interface SessionCookieOptions {
230
231
  * - `SameSite=Lax` — not attached to cross-site POSTs, which is CSRF's whole mechanism.
231
232
  * - `Path=/`, no `Domain` — required by `__Host-`; a sibling subdomain cannot set or read it.
232
233
  * - `Max-Age` — the client drops it at the absolute expiry, matching the server's ceiling.
234
+ *
235
+ * The two numbers behind that last attribute are screened here rather than merged first, because
236
+ * they are two different options and an error naming the one the caller did not pass is a `fix:`
237
+ * nobody can follow. `Max-Age` is `delta-seconds` — digits — so `NaN`, `Infinity` and `1.5` are
238
+ * attributes a browser DISCARDS: the cookie then lives until the tab closes, which is the client
239
+ * half of the session ceiling gone with no error anywhere. Zero is legitimate and stays legal, at
240
+ * both ends: it means "expire now", which is exactly what `clearSessionCookie` emits.
233
241
  */
234
242
  export function sessionCookie(
235
243
  token: string,
@@ -237,7 +245,22 @@ export function sessionCookie(
237
245
  options?: SessionCookieOptions,
238
246
  ): string {
239
247
  const name = options?.name ?? policy.cookieName;
240
- const maxAge = options?.maxAgeSeconds ?? Math.floor(policy.absoluteTtlMs / 1000);
248
+ const maxAge =
249
+ options?.maxAgeSeconds === undefined
250
+ ? Math.floor(
251
+ assertFiniteAuthCount(
252
+ 'session.absoluteTtlMs',
253
+ policy.absoluteTtlMs,
254
+ 'the cookie carries `Max-Age=NaN`, which is not delta-seconds, so the browser drops the attribute and keeps the session cookie for the whole browsing session',
255
+ 1,
256
+ ) / 1000,
257
+ )
258
+ : assertFiniteAuthCount(
259
+ 'session.cookie.maxAgeSeconds',
260
+ options.maxAgeSeconds,
261
+ 'the cookie carries an attribute that is not delta-seconds, so the browser drops it and keeps the session cookie for the whole browsing session',
262
+ 0,
263
+ );
241
264
  return `${name}=${token}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
242
265
  }
243
266
 
package/src/tokens.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // exact same one, and re-exporting it here keeps every existing `from '@ultimat3/auth'` import
6
6
  // working.
7
7
 
8
- import { timingSafeEqual } from '@ultimat3/core';
8
+ import { finiteCount, timingSafeEqual } from '@ultimat3/core';
9
9
 
10
10
  export { timingSafeEqual };
11
11
 
@@ -24,9 +24,22 @@ export function base64Url(bytes: Uint8Array): string {
24
24
  return btoa(binary).replace(BASE64URL_UNSAFE, (char) => BASE64URL_REPLACEMENTS[char] ?? '');
25
25
  }
26
26
 
27
- /** 32 bytes -> 43 base64url chars. Opaque by construction: it encodes nothing about the user. */
27
+ /**
28
+ * 32 bytes -> 43 base64url chars. Opaque by construction: it encodes nothing about the user.
29
+ *
30
+ * The default is not a screen — `??` guards nullish and `NaN` is not nullish — and
31
+ * `new Uint8Array(NaN)` is a zero-length array rather than a throw, so
32
+ * `randomToken(Number(process.env.TOKEN_BYTES))` on an unset variable answered `''`: the function
33
+ * whose entire job is an unguessable secret returning the empty string, with nothing said.
34
+ * `-1` was a bare uncoded `RangeError` out of the package. `min: 1`, because a zero-byte token is
35
+ * that same empty string — not a weak secret, no secret.
36
+ *
37
+ * `finiteCount` from core rather than this package's `assertFiniteAuthCount`: that one's refusal
38
+ * spells the edit `defineAuth({ <key>: … })` (`errors.ts`), and there is no `defineAuth` key here —
39
+ * the edit is at the call site, and a `fix:` naming a file that cannot hold it is worse than none.
40
+ */
28
41
  export function randomToken(byteLength = 32): string {
29
- return base64Url(randomBytes(byteLength));
42
+ return base64Url(randomBytes(finiteCount('randomToken', 'byteLength', byteLength, 1)));
30
43
  }
31
44
 
32
45
  /**
package/src/verify.ts CHANGED
@@ -7,6 +7,7 @@ import type { Clock } from '@ultimat3/core';
7
7
  import type { AuthVerification, VerificationStore } from './adapter';
8
8
  import { normaliseEmail } from './email';
9
9
  import { AuthError } from './errors';
10
+ import { assertFiniteAuthCount } from './policy-numbers';
10
11
  import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
11
12
 
12
13
  /**
@@ -67,7 +68,18 @@ export async function issueVerification(
67
68
  ): Promise<IssuedVerification> {
68
69
  const now = runtime.clock.now();
69
70
  const token = randomToken(32);
70
- const ttl = input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[input.purpose];
71
+ // Screened HERE, above the write, not beside the `Date` it feeds. Unscreened this threw a bare
72
+ // `RangeError` out of the package — `new Date(now + NaN)` is an Invalid Date and `toISOString()`
73
+ // refuses one — on the line after `putVerification` had already resolved. The row survived the
74
+ // failure with an expiry `consumeVerification` reads as `now >= NaN`, which is false for ever:
75
+ // a reset link that never expires. And because the write upserts on `(purpose, identifier)`, it
76
+ // had replaced whatever live token that address held on its way to failing.
77
+ const ttl = assertFiniteAuthCount(
78
+ 'verification.ttlMs',
79
+ input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[input.purpose],
80
+ 'the stored expiry is an Invalid Date, `now >= NaN` is false, and the mailed link is a password that never expires',
81
+ 1,
82
+ );
71
83
  const expiresAt = new Date(now.getTime() + ttl);
72
84
  // The FOURTH identity door, and the one that did not normalise: `register`, `login`,
73
85
  // `profileEmail` and `accountKey` all do. `putVerification` upserts on `(purpose, identifier)`