@ultimat3/auth
Advanced tools
| // 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" | ||
| } | ||
| } |
+7
-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 |
+25
-0
@@ -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({ |
+13
-0
@@ -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; |
+23
-0
@@ -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), |
+25
-1
@@ -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)}`); |
+23
-2
@@ -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) { |
+15
-1
@@ -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) { |
+10
-1
@@ -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; |
+24
-1
@@ -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 @@ } |
+16
-3
@@ -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 @@ |
+13
-1
@@ -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`, |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
410449
5.92%46
4.55%6830
5.35%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
Updated
Updated
Updated