New:Socket for Asana Is Now Available.Learn more
Get Started

@ultimat3/auth

Package Overview
Dependencies
Maintainers
1
Versions
26
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
16.0.0
to
17.0.0
+26
src/auth-fixture.ts
// Single responsibility: the three values every credential-flow suite needs — cheap KDF
// parameters, the password they all sign in with, and the `AuthError` catcher. Three test files
// share them, and three private copies is three chances for one to drift from what the flow
// actually enforces. Not part of the public API — `index.ts` deliberately does not re-export it.
import { AuthError } from './errors';
import type { PasswordParams } from './password';
// Fast KDF parameters: these tests are about the credential flow, not argon2's cost.
export const FAST_PARAMS: PasswordParams = { algorithm: 'argon2id', memoryCost: 8192, timeCost: 1 };
export const PASSWORD = 'correct-horse-battery-staple-42';
/** Captures the thrown `AuthError`, or `undefined` when the call unexpectedly resolved. The
* caller's `expect(error?.code).toBe(...)` is then the assertion that fails, naming the code it
* wanted — a sentinel thrown from in here would carry no code and no fix. Anything that is not
* an `AuthError` is rethrown untouched: this helper never swallows an unexpected failure. */
export const caught = async (fn: () => Promise<unknown>): Promise<AuthError | undefined> => {
try {
await fn();
return undefined;
} catch (error) {
if (error instanceof AuthError) return error;
throw error;
}
};
// Single responsibility: the numeric screen over every option this package bounds anything with —
// the three policies `defineAuth` resolves, and the runtime options (`jwks`, the OAuth legs, TOTP
// drift, the limiter's key cap) that arrive on a call rather than through config. One file, because
// the rule is one rule: a duration, a length and an allowance are all "a whole positive number, and
// NaN is what an unset environment variable parses to".
import { authPolicyNumberInvalid } from './errors';
import type { PasswordPolicy } from './password';
import type { AuthRateLimitPolicy } from './rate-limit';
import type { SessionPolicy } from './session';
const WHOLE_POSITIVE = 'a whole number greater than zero';
const WHOLE_NON_NEGATIVE = 'a whole number of zero or more';
/**
* `Number.isSafeInteger`, not `Number.isFinite`: these are millisecond durations and attempt
* counts, and past 2^53 a double cannot name its own successor — a "duration" up there is already
* a rounded one, and `now + it` no longer moves.
*
* The `Finite` in the name is load-bearing: `bun run finite-bounds` recognises a repair by the
* shape of the CALL, so a screen named `whole` left every call site reading as unchecked.
*/
export function assertFiniteAuthCount(
key: string,
value: number,
consequence: string,
min: 0 | 1,
): number {
if (Number.isSafeInteger(value) && value >= min) return value;
throw authPolicyNumberInvalid(
key,
value,
min === 1 ? WHOLE_POSITIVE : WHOLE_NON_NEGATIVE,
consequence,
);
}
/** Both clocks and the slide between them. A session that never expires is the failure here. */
export function assertSessionPolicy(policy: SessionPolicy): void {
assertFiniteAuthCount(
'session.absoluteTtlMs',
policy.absoluteTtlMs,
'the absolute expiry becomes an Invalid Date and `now >= NaN` is false, so the session never expires on that clock',
1,
);
assertFiniteAuthCount(
'session.idleTtlMs',
policy.idleTtlMs,
'`now - lastSeenAt >= NaN` is false, so a session idle for years still reports itself live',
1,
);
if (policy.idleSlideMs !== undefined) {
assertFiniteAuthCount(
'session.idleSlideMs',
policy.idleSlideMs,
'the write that moves `lastSeenAt` forward is skipped or taken on every single request',
0,
);
}
}
/** The length rule, and the two KDF costs that decide what a stolen hash is worth. */
export function assertPasswordPolicy(policy: PasswordPolicy): void {
assertFiniteAuthCount(
'password.minLength',
policy.minLength,
'`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',
1,
);
assertFiniteAuthCount(
'password.params.memoryCost',
policy.params.memoryCost,
'argon2 refuses it several frames below the config line that set it, on the first registration',
1,
);
assertFiniteAuthCount(
'password.params.timeCost',
policy.params.timeCost,
'argon2 refuses it several frames below the config line that set it, on the first registration',
1,
);
}
/**
* The lockout numbers. These were already refused — by `assertAuthLimiterPolicy`, whose `NaN ===
* NaN` comparison fails — but as `X_AUTH_LIMITER_POLICY_MISMATCH`, which tells an operator the
* limiter enforces different numbers than the app declared. It does not: the number is a typo.
*/
export function assertRateLimitPolicy(policy: AuthRateLimitPolicy): void {
assertFiniteAuthCount(
'rateLimit.maxAttempts',
policy.maxAttempts,
'`failures.length >= NaN` is false, so the lockout never engages and guessing is unlimited',
1,
);
assertFiniteAuthCount(
'rateLimit.windowMs',
policy.windowMs,
'every recorded failure falls outside the window immediately, so nothing ever accumulates',
1,
);
assertFiniteAuthCount(
'rateLimit.lockoutMs',
policy.lockoutMs,
'`now < lockedUntil` is false, so a lockout that was established holds nobody',
1,
);
if (policy.orgMaxAttempts !== undefined) {
assertFiniteAuthCount(
'rateLimit.orgMaxAttempts',
policy.orgMaxAttempts,
'the tenant bucket never fills, so one org can saturate the shared limiter',
1,
);
}
if (policy.maxKeys !== undefined) {
assertFiniteAuthCount(
'rateLimit.maxKeys',
policy.maxKeys,
'the in-memory table has no ceiling, so a spray of distinct keys is memory the attacker chooses',
1,
);
}
}
+56
-1

@@ -19,2 +19,56 @@ # @ultimat3/auth — agent notes

- **A policy NUMBER is screened at `defineAuth`, `As of 2026-08-26`** (`policy-numbers.ts`,
`X_CONFIG_INVALID`). `session.absoluteTtlMs`, `session.idleTtlMs`, `session.idleSlideMs`,
`password.minLength`, the two argon2 costs and every `rateLimit` number. Measured with `NaN` —
what `Number(process.env.SESSION_TTL_MS)` answers when the variable is unset, and not nullish, so
the spread over the defaults keeps it: `now >= NaN` is false, so a session idle since 2000
reported `absoluteExpired: false` AND `idleExpired: false`; `password.length < NaN` is false and
the two-distinct-characters rule is guarded by `length > 0`, so the EMPTY password was accepted.
A rule whose comparison is false for every input is not a loose rule, it is no rule.
- **Every RUNTIME number this package bounds anything with is screened too, `As of 2026-08-26`** —
`assertFiniteAuthCount` in `policy-numbers.ts`, one file for both halves. `jwks.ttlMs`
(`now >= fetchedAt + NaN` is false, so a provider's key rotation is never refetched and every
login against the new `kid` fails until the process restarts), the three OAuth legs' `timeoutMs`
(`AbortSignal.timeout(NaN)` THROWS, and it is screened OUTSIDE each leg's `try` — inside it, the
catch rendered an app config typo as the identity provider being unreachable), the limiter's
`maxKeys` (`Math.max(1, Math.floor(NaN))` is `NaN`, so the sweep bounding a table half of whose
keys are attacker-chosen never ran) and `mfa.drift`. **`drift` is the one that hangs**: it is a
LOOP BOUND, not a comparison — `for (offset = -Infinity; offset <= Infinity; offset += 1)` never
terminates, measured, synchronously, on the login path — and `NaN` makes the loop never run at
all, so every correct code is rejected as if it were wrong. Editing `verifyTotp` with the screen
removed WEDGES `mfa.test.ts` rather than failing it; mutate with the `NaN` case.
**That sentence was false when it was written and four more sites closed it, `As of 2026-08-26`.**
It named the options `defineAuth` resolves and the ones a *service* call takes, and missed every
number a **cookie** or a **mailed link** is bounded by. `oauth.handshake.ttlMs` is the one that
matters: `openHandshake`'s `now - issuedAt > NaN` is false, so a year-old sealed handshake — the
state, nonce and PKCE verifier that ARE the callback leg's CSRF defence — opened and returned its
verifier, while `handshakeCookie` wrote `Max-Age=NaN`, which is not `delta-seconds`, so the
browser dropped the attribute and kept the cookie for the whole session. One number, both ends of
the deadline, off together and silently. `session.cookie.maxAgeSeconds` is the same shape beside
a `policy.absoluteTtlMs` that WAS screened; `kdf.maxConcurrent`/`kdf.maxQueued` are the second
thing that WEDGES after `mfa.drift` — core asks `active < maxConcurrent` then
`waiters.length >= maxQueued`, both false for `NaN`, so every `hashPassword` on the box parks in
an unbounded queue nothing releases and login stops answering rather than shedding.
- **A number is screened ABOVE the write it feeds, not beside the arithmetic** (`As of
2026-08-26`). `issueVerification` read `input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[purpose]`
straight into `new Date(now + ttl)` and threw a bare `RangeError` — `toISOString()` refuses an
Invalid Date — on the line AFTER `putVerification` had already resolved. Two faults from one
number: an uncoded throw out of the package, and a durable row whose expiry `consumeVerification`
compares as `now >= NaN`, false for ever, i.e. a password-reset link that never expires. The
write also upserts on `(purpose, identifier)`, so the failing call had destroyed whatever live
token that address held on its way out. `verify.test.ts` asserts the ordering directly (no row
written, no mail sent, the earlier token still redeemable) — moving the screen below the store
call fails those three and leaves the coded-refusal case green.
- **The minimum is per option, and zero is usually legitimate.** `assertFiniteAuthCount`'s `min` is
`0 | 1` because only the call site knows what zero MEANS, and picking `1` for tidiness is how a
screen breaks a working deployment while every test still passes. `maxAgeSeconds: 0` is how a
cookie is expired — it is what sign-out emits — and `{ maxConcurrent: 0, maxQueued: 0 }` is a
gate that refuses every hash, which is how `password.test.ts` proves the unreadable-hash path
burns the same KDF a wrong password does. Both take `min: 0`. A TTL takes `min: 1`, because a
handshake or a mailed link that is already expired when it is issued is not a configuration.
- Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a

@@ -398,3 +452,3 @@ parameter to it re-opens account enumeration.

|---|---|
| `auth.ts` | `defineAuth`, entity schemas, `login`/`register`/`authenticate`/`logout` |
| `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) |
| `policy-bridge.ts` | the one funnel: identity → `Actor`, all four `ActorKind`s |

@@ -419,2 +473,3 @@ | `session.ts` | two expiries, rotation, revocation, device list, the cookie |

| `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
| `auth-fixture.ts` | the KDF parameters, the password and the `AuthError` catcher the three `auth*.test.ts` suites share. Off `index.ts` |
| `oauth-profile.ts` | claims or userinfo → one `OAuthProfile` |

@@ -421,0 +476,0 @@ | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |

+4
-4
{
"name": "@ultimat3/auth",
"version": "16.0.0",
"version": "17.0.0",
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",

@@ -34,6 +34,6 @@ "license": "MIT",

"dependencies": {
"@ultimat3/core": "16.0.0",
"@ultimat3/db": "16.0.0",
"@ultimat3/schema": "16.0.0"
"@ultimat3/core": "17.0.0",
"@ultimat3/db": "17.0.0",
"@ultimat3/schema": "17.0.0"
}
}

@@ -21,2 +21,3 @@ // Single responsibility: the one configuration entry point and the credential flows built on

import { type PolicyActor, resolveActor } from './policy-bridge';
import { assertPasswordPolicy, assertRateLimitPolicy, assertSessionPolicy } from './policy-numbers';
import {

@@ -180,2 +181,8 @@ type AuthLimiter,

const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, ...config.rateLimit };
// Screened on the MERGED policy, before anything is built from it: an override is spread over
// defaults the author never restated, so the resolved object is the only one that can be judged
// — the same reason `defineHttpConfig` judges the merged `cors` rather than the input.
assertSessionPolicy(session);
assertPasswordPolicy(password);
assertRateLimitPolicy(rateLimit);
// Three answers in precedence order, and the middle one is why the seam exists: what this call

@@ -182,0 +189,0 @@ // passed, then what the HOST installed (`configureAuthLimiters`, filled by the boot that owns

@@ -219,2 +219,27 @@ // Single responsibility: this package's stable X_ codes and the factories that build them.

/**
* A policy NUMBER that is not one, refused where `defineAuth` still names the key.
*
* Every one of these arrives as `Number(process.env.SESSION_TTL_MS)` as often as a literal, and
* that is `NaN` for an unset variable — not nullish, so the spread over the defaults keeps it, and
* then every comparison it reaches is false. What that produces is not a short session or a weak
* password, it is the RULE not existing: `now >= NaN` is false, so a session never expires on
* either clock; `password.length < NaN` is false, so the empty password was accepted. Both were
* measured. `X_CONFIG_INVALID` is core's code, borrowed rather than re-declared, beside
* `mfaRequiredUnenforceable` above and for the same reason — a declaration this package cannot
* honour is not a new kind of failure.
*/
export const authPolicyNumberInvalid = (
key: string,
value: number,
expected: string,
consequence: string,
): AuthError =>
new AuthError({
code: 'X_CONFIG_INVALID',
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`,
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)`,
meta: { option: key, value: String(value) },
});
export const passwordWeak = (reasons: readonly string[]): AuthError =>

@@ -221,0 +246,0 @@ new AuthError({

@@ -15,2 +15,3 @@ // Single responsibility: verifying a JWT's signature against a provider's published JWKS.

import type { OAuthFetch } from './oauth-exchange';
import { assertFiniteAuthCount } from './policy-numbers';
import { base64UrlBytes } from './tokens';

@@ -116,3 +117,15 @@

const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
assertFiniteAuthCount(
'jwks.ttlMs',
ttlMs,
'`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',
1,
);
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
assertFiniteAuthCount(
'jwks.timeoutMs',
timeoutMs,
'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',
1,
);
let keys = new Map<string, CryptoKey>();

@@ -119,0 +132,0 @@ let fetchedAtMs = Number.NEGATIVE_INFINITY;

@@ -11,2 +11,3 @@ // Single responsibility: bounding how many argon2 hashes this process runs at once.

import { kdfOverloaded } from './errors';
import { assertFiniteAuthCount } from './policy-numbers';

@@ -47,2 +48,24 @@ export interface KdfLimits {

export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
// Screened at the CONSTRUCTOR, because this pair WEDGES rather than fails: core asks
// `active < maxConcurrent` and then `waiters.length >= maxQueued`, and both are false for `NaN`,
// so every hash on the box parks in a queue with no bound and nothing to release it — login
// stops answering instead of shedding.
//
// ZERO is legitimate at BOTH, which is why the minimum is 0 and not 1: `maxQueued: 0` means
// "shed at the width, never wait", and `{ maxConcurrent: 0, maxQueued: 0 }` is a gate that
// refuses every hash — `password.test.ts`'s way of proving the unreadable-hash path burns the
// same KDF the other two failures do. Refusing zero here would have failed that test, which is
// the deployment this screen must not break.
assertFiniteAuthCount(
'kdf.maxConcurrent',
limits.maxConcurrent,
'`active < NaN` is false, so no hash ever starts and every caller queues instead',
0,
);
assertFiniteAuthCount(
'kdf.maxQueued',
limits.maxQueued,
'`waiters.length >= NaN` is false, so the queue this gate exists to bound has no bound at all',
0,
);
return createFlightGate(limits, {

@@ -49,0 +72,0 @@ overflow: (state) => kdfOverloaded(state.active, state.queued),

@@ -6,4 +6,6 @@ // Single responsibility: TOTP (RFC 6238) and recovery codes. The drift window is explicit and

import { finiteCount } from '@ultimat3/core';
import type { Auth } from './auth';
import { mfaSecretInvalid } from './errors';
import { assertFiniteAuthCount } from './policy-numbers';
import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';

@@ -170,3 +172,13 @@

if (base32Decode(input.secret).length === 0) return { ok: false, step: null };
// A LOOP BOUND, screened before it becomes one: measured, `drift: Infinity` never terminates
// (`-Infinity + 1` is `-Infinity`) — a synchronous infinite loop on the login path, past every
// AbortSignal — and `drift: NaN` makes `-NaN <= NaN` false, so the loop never runs and every
// correct code is rejected as if it were wrong. Zero is legitimate: the current step only.
const drift = input.drift ?? TOTP_DRIFT_STEPS;
assertFiniteAuthCount(
'mfa.drift',
drift,
'the verification loop either never terminates (Infinity) or never runs at all (NaN), and the second answers "wrong code" to every correct one',
0,
);
const current = totpStep(input.at);

@@ -301,5 +313,17 @@ const candidate = input.code.replaceAll(' ', '');

/**
* `count` is a LOOP BOUND, so it fails in both directions and neither is a smaller set of codes.
* `NaN` — what `Number(process.env.RECOVERY_CODES)` answers for an unset variable, and not nullish,
* so the default never sees it — makes `index < count` false at once and enrols a user with ZERO
* ways back into their account, in a well-formed `{ codes: [], hashes: [] }` that no caller can
* tell from a real one. `Infinity` is the other end and it WEDGES: measured, the loop never
* terminates, synchronously, on the enrolment path. Same shape as `mfa.drift`.
*
* `finiteCount` from core rather than `assertFiniteAuthCount`, for the reason `randomToken` gives:
* that refusal spells `defineAuth({ <key>: … })` and this is a call-site argument.
*/
export function generateRecoveryCodes(count = 10): RecoveryCodeSet {
const wanted = finiteCount('generateRecoveryCodes', 'count', count, 1);
const codes: string[] = [];
for (let index = 0; index < count; index += 1) {
for (let index = 0; index < wanted; index += 1) {
const raw = base32Encode(randomBytes(10)).slice(0, 16);

@@ -306,0 +330,0 @@ codes.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}`);

@@ -13,2 +13,3 @@ // Single responsibility: where the handshake lives between the redirect and the callback. The

import { hasOAuthProvider } from './oauth-registry';
import { assertFiniteAuthCount } from './policy-numbers';
import { type RequestLike, readCookie } from './session';

@@ -34,2 +35,22 @@ import { base64Url, timingSafeEqual } from './tokens';

/**
* One screen for both ends of the deadline, because it is one number bounding two things: the
* server comparison in `openHandshake` and the `Max-Age` the browser is asked to honour. `NaN`
* switches off both at once and neither says anything — `now - issuedAt > NaN` is false forever,
* and `Max-Age=NaN` is not `delta-seconds`, so a browser drops the attribute and keeps the cookie
* until the tab closes. What survives is the state, nonce and PKCE verifier that are the CSRF
* defence of the callback leg, replayable a year later.
*
* Screened at every reader rather than once at seal time: the two legs are two HTTP requests and
* two calls, so a check on the way out is one the way back never runs.
*/
function handshakeTtlMs(options: HandshakeSealOptions | undefined): number {
return assertFiniteAuthCount(
'oauth.handshake.ttlMs',
options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS,
'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',
1,
);
}
/** `wiki/Configuration.md` requires it at >=32 chars for the `web` role; this is that gate. */

@@ -151,3 +172,3 @@ const MIN_SECRET_LENGTH = 32;

const clock = options?.clock ?? systemClock;
if (clock.now().getTime() - issuedAt > (options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS)) {
if (clock.now().getTime() - issuedAt > handshakeTtlMs(options)) {
throw oauthStateInvalid(provider, 'the stored handshake expired before the callback arrived');

@@ -170,3 +191,3 @@ }

): string {
const maxAge = Math.floor((options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS) / 1000);
const maxAge = Math.floor(handshakeTtlMs(options) / 1000);
// The handshake already names its provider, so the two legs cannot disagree about the name.

@@ -173,0 +194,0 @@ const name = options?.name ?? handshakeCookieName(handshake.provider);

@@ -11,3 +11,8 @@ // Single responsibility: turning an OIDC issuer URL into an `OAuthProvider`, by reading the

import type { OAuthFetch } from './oauth-exchange';
import { assertFiniteAuthCount } from './policy-numbers';
/** Named in the refusal above the one `fetch` this file makes. */
const TIMEOUT_CONSEQUENCE =
'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';
const DEFAULT_TIMEOUT_MS = 10_000;

@@ -54,2 +59,11 @@

const doFetch: OAuthFetch = input.fetch ?? ((target, init) => globalThis.fetch(target, init));
// Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
// catch around this fetch renders every throw as a provider failure — so a typo in the app's
// own config read as the identity provider being unreachable.
const timeoutMs = assertFiniteAuthCount(
'oauth.discovery.timeoutMs',
input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
TIMEOUT_CONSEQUENCE,
1,
);

@@ -61,3 +75,3 @@ let response: Response;

headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(input.timeoutMs ?? DEFAULT_TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
});

@@ -64,0 +78,0 @@ } catch (error) {

@@ -25,2 +25,3 @@ // Single responsibility: the authorization-code leg — the client credentials it needs and the

import { providerFor } from './oauth-registry';
import { assertFiniteAuthCount } from './policy-numbers';

@@ -33,2 +34,6 @@ /** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */

/** Named in the refusal above the one `fetch` this file makes. */
const TIMEOUT_CONSEQUENCE =
'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';
const DEFAULT_TIMEOUT_MS = 10_000;

@@ -163,2 +168,11 @@ const MAX_DETAIL_LENGTH = 200;

const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
// Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
// catch around this fetch renders every throw as a provider failure — so a typo in the app's
// own config read as the identity provider being unreachable.
const timeoutMs = assertFiniteAuthCount(
'oauth.exchange.timeoutMs',
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
TIMEOUT_CONSEQUENCE,
1,
);
const url = providerFor(provider).tokenUrl;

@@ -177,3 +191,3 @@

body: body.toString(),
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
});

@@ -180,0 +194,0 @@ } catch (error) {

@@ -18,3 +18,8 @@ // Single responsibility: one normalised identity out of whichever surface the provider offers.

import { providerFor } from './oauth-registry';
import { assertFiniteAuthCount } from './policy-numbers';
/** Named in the refusal above the one `fetch` this file makes. */
const TIMEOUT_CONSEQUENCE =
'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';
const DEFAULT_TIMEOUT_MS = 10_000;

@@ -53,2 +58,11 @@

const doFetch: OAuthFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
// Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
// catch around this fetch renders every throw as a provider failure — so a typo in the app's
// own config read as the identity provider being unreachable.
const timeoutMs = assertFiniteAuthCount(
'oauth.profile.timeoutMs',
options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
TIMEOUT_CONSEQUENCE,
1,
);
let response: Response;

@@ -63,3 +77,3 @@ try {

},
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
signal: AbortSignal.timeout(timeoutMs),
});

@@ -66,0 +80,0 @@ } catch (error) {

@@ -14,2 +14,3 @@ // Single responsibility: throttling and lockout for credential paths, plus the one generic

} from './errors';
import { assertFiniteAuthCount } from './policy-numbers';

@@ -161,3 +162,11 @@ /**

const buckets = new Map<string, Bucket>();
const maxKeys = Math.max(1, Math.floor(policy.maxKeys ?? DEFAULT_MAX_AUTH_LIMIT_KEYS));
// Screened, not clamped: `Math.floor(NaN)` is `NaN`, so `buckets.size > maxKeys` is false and
// the sweep that bounds this table never runs. Half these keys are attacker-chosen (`ipKey`),
// which is what makes the ceiling the only thing between a spray and this process's memory.
const maxKeys = assertFiniteAuthCount(
'rateLimit.maxKeys',
policy.maxKeys ?? DEFAULT_MAX_AUTH_LIMIT_KEYS,
'the in-memory table has no ceiling, so a spray of distinct keys is memory the attacker chooses',
1,
);
const evictTo = Math.max(1, Math.floor(maxKeys * 0.9));

@@ -164,0 +173,0 @@ let lastSweepMs = Number.NEGATIVE_INFINITY;

@@ -10,2 +10,3 @@ // Single responsibility: session lifetime and the cookie that carries it. Two expiries are

import { sessionExpired, sessionUnknown } from './errors';
import { assertFiniteAuthCount } from './policy-numbers';
import { randomToken, sha256Hex, timingSafeEqual } from './tokens';

@@ -234,2 +235,9 @@

* - `Max-Age` — the client drops it at the absolute expiry, matching the server's ceiling.
*
* The two numbers behind that last attribute are screened here rather than merged first, because
* they are two different options and an error naming the one the caller did not pass is a `fix:`
* nobody can follow. `Max-Age` is `delta-seconds` — digits — so `NaN`, `Infinity` and `1.5` are
* attributes a browser DISCARDS: the cookie then lives until the tab closes, which is the client
* half of the session ceiling gone with no error anywhere. Zero is legitimate and stays legal, at
* both ends: it means "expire now", which is exactly what `clearSessionCookie` emits.
*/

@@ -242,3 +250,18 @@ export function sessionCookie(

const name = options?.name ?? policy.cookieName;
const maxAge = options?.maxAgeSeconds ?? Math.floor(policy.absoluteTtlMs / 1000);
const maxAge =
options?.maxAgeSeconds === undefined
? Math.floor(
assertFiniteAuthCount(
'session.absoluteTtlMs',
policy.absoluteTtlMs,
'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',
1,
) / 1000,
)
: assertFiniteAuthCount(
'session.cookie.maxAgeSeconds',
options.maxAgeSeconds,
'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',
0,
);
return `${name}=${token}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;

@@ -245,0 +268,0 @@ }

@@ -8,3 +8,3 @@ // Single responsibility: the secret primitives every other file in this package shares —

import { timingSafeEqual } from '@ultimat3/core';
import { finiteCount, timingSafeEqual } from '@ultimat3/core';

@@ -28,5 +28,18 @@ export { timingSafeEqual };

/** 32 bytes -> 43 base64url chars. Opaque by construction: it encodes nothing about the user. */
/**
* 32 bytes -> 43 base64url chars. Opaque by construction: it encodes nothing about the user.
*
* The default is not a screen — `??` guards nullish and `NaN` is not nullish — and
* `new Uint8Array(NaN)` is a zero-length array rather than a throw, so
* `randomToken(Number(process.env.TOKEN_BYTES))` on an unset variable answered `''`: the function
* whose entire job is an unguessable secret returning the empty string, with nothing said.
* `-1` was a bare uncoded `RangeError` out of the package. `min: 1`, because a zero-byte token is
* that same empty string — not a weak secret, no secret.
*
* `finiteCount` from core rather than this package's `assertFiniteAuthCount`: that one's refusal
* spells the edit `defineAuth({ <key>: … })` (`errors.ts`), and there is no `defineAuth` key here —
* the edit is at the call site, and a `fix:` naming a file that cannot hold it is worse than none.
*/
export function randomToken(byteLength = 32): string {
return base64Url(randomBytes(byteLength));
return base64Url(randomBytes(finiteCount('randomToken', 'byteLength', byteLength, 1)));
}

@@ -33,0 +46,0 @@

@@ -10,2 +10,3 @@ // Single responsibility: email verification and password reset — the two flows where a link in

import { AuthError } from './errors';
import { assertFiniteAuthCount } from './policy-numbers';
import { randomToken, sha256Hex, timingSafeEqual } from './tokens';

@@ -71,3 +72,14 @@

const token = randomToken(32);
const ttl = input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[input.purpose];
// Screened HERE, above the write, not beside the `Date` it feeds. Unscreened this threw a bare
// `RangeError` out of the package — `new Date(now + NaN)` is an Invalid Date and `toISOString()`
// refuses one — on the line after `putVerification` had already resolved. The row survived the
// failure with an expiry `consumeVerification` reads as `now >= NaN`, which is false for ever:
// a reset link that never expires. And because the write upserts on `(purpose, identifier)`, it
// had replaced whatever live token that address held on its way to failing.
const ttl = assertFiniteAuthCount(
'verification.ttlMs',
input.ttlMs ?? DEFAULT_VERIFICATION_TTL_MS[input.purpose],
'the stored expiry is an Invalid Date, `now >= NaN` is false, and the mailed link is a password that never expires',
1,
);
const expiresAt = new Date(now.getTime() + ttl);

@@ -74,0 +86,0 @@ // The FOURTH identity door, and the one that did not normalise: `register`, `login`,