@ultimat3/auth 16.0.0 → 18.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 +56 -1
- package/package.json +5 -5
- package/src/auth-fixture.ts +26 -0
- package/src/auth.ts +7 -0
- package/src/errors.ts +25 -0
- package/src/jwks.ts +13 -0
- package/src/kdf-gate.ts +23 -0
- package/src/mfa.ts +25 -1
- package/src/oauth-cookie.ts +23 -2
- package/src/oauth-discovery.ts +15 -1
- package/src/oauth-exchange.ts +15 -1
- package/src/oauth-profile.ts +15 -1
- package/src/policy-numbers.ts +126 -0
- package/src/rate-limit.ts +10 -1
- package/src/session.ts +24 -1
- package/src/tokens.ts +16 -3
- package/src/verify.ts +13 -1
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": "
|
|
3
|
+
"version": "18.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"engines": {
|
|
27
|
-
"bun": ">=1.
|
|
27
|
+
"bun": ">=1.4.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "18.0.0",
|
|
35
|
+
"@ultimat3/db": "18.0.0",
|
|
36
|
+
"@ultimat3/schema": "18.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 <
|
|
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
|
}
|
package/src/oauth-cookie.ts
CHANGED
|
@@ -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
|
|
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
|
|
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`;
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -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(
|
|
73
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
60
74
|
});
|
|
61
75
|
} catch (error) {
|
|
62
76
|
throw oauthExchangeFailed({
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -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(
|
|
188
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
175
189
|
});
|
|
176
190
|
} catch (error) {
|
|
177
191
|
// The rendered value goes to the LOG, never into the `detail:`.
|
package/src/oauth-profile.ts
CHANGED
|
@@ -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(
|
|
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,126 @@
|
|
|
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 NOT load-bearing, `As of 2026-08-26`. It was: `bun run
|
|
21
|
+
* finite-bounds` matched a repair on the callee's NAME, so a screen named `whole` left every call
|
|
22
|
+
* site reading as unchecked. It now reads `SCREENING_CALLEES` in `scripts/lib/finite-screens.ts`,
|
|
23
|
+
* so a rename is a row edit rather than a gate failure.
|
|
24
|
+
*/
|
|
25
|
+
export function assertFiniteAuthCount(
|
|
26
|
+
key: string,
|
|
27
|
+
value: number,
|
|
28
|
+
consequence: string,
|
|
29
|
+
min: 0 | 1,
|
|
30
|
+
): number {
|
|
31
|
+
if (Number.isSafeInteger(value) && value >= min) return value;
|
|
32
|
+
throw authPolicyNumberInvalid(
|
|
33
|
+
key,
|
|
34
|
+
value,
|
|
35
|
+
min === 1 ? WHOLE_POSITIVE : WHOLE_NON_NEGATIVE,
|
|
36
|
+
consequence,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Both clocks and the slide between them. A session that never expires is the failure here. */
|
|
41
|
+
export function assertSessionPolicy(policy: SessionPolicy): void {
|
|
42
|
+
assertFiniteAuthCount(
|
|
43
|
+
'session.absoluteTtlMs',
|
|
44
|
+
policy.absoluteTtlMs,
|
|
45
|
+
'the absolute expiry becomes an Invalid Date and `now >= NaN` is false, so the session never expires on that clock',
|
|
46
|
+
1,
|
|
47
|
+
);
|
|
48
|
+
assertFiniteAuthCount(
|
|
49
|
+
'session.idleTtlMs',
|
|
50
|
+
policy.idleTtlMs,
|
|
51
|
+
'`now - lastSeenAt >= NaN` is false, so a session idle for years still reports itself live',
|
|
52
|
+
1,
|
|
53
|
+
);
|
|
54
|
+
if (policy.idleSlideMs !== undefined) {
|
|
55
|
+
assertFiniteAuthCount(
|
|
56
|
+
'session.idleSlideMs',
|
|
57
|
+
policy.idleSlideMs,
|
|
58
|
+
'the write that moves `lastSeenAt` forward is skipped or taken on every single request',
|
|
59
|
+
0,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The length rule, and the two KDF costs that decide what a stolen hash is worth. */
|
|
65
|
+
export function assertPasswordPolicy(policy: PasswordPolicy): void {
|
|
66
|
+
assertFiniteAuthCount(
|
|
67
|
+
'password.minLength',
|
|
68
|
+
policy.minLength,
|
|
69
|
+
'`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',
|
|
70
|
+
1,
|
|
71
|
+
);
|
|
72
|
+
assertFiniteAuthCount(
|
|
73
|
+
'password.params.memoryCost',
|
|
74
|
+
policy.params.memoryCost,
|
|
75
|
+
'argon2 refuses it several frames below the config line that set it, on the first registration',
|
|
76
|
+
1,
|
|
77
|
+
);
|
|
78
|
+
assertFiniteAuthCount(
|
|
79
|
+
'password.params.timeCost',
|
|
80
|
+
policy.params.timeCost,
|
|
81
|
+
'argon2 refuses it several frames below the config line that set it, on the first registration',
|
|
82
|
+
1,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The lockout numbers. These were already refused — by `assertAuthLimiterPolicy`, whose `NaN ===
|
|
88
|
+
* NaN` comparison fails — but as `X_AUTH_LIMITER_POLICY_MISMATCH`, which tells an operator the
|
|
89
|
+
* limiter enforces different numbers than the app declared. It does not: the number is a typo.
|
|
90
|
+
*/
|
|
91
|
+
export function assertRateLimitPolicy(policy: AuthRateLimitPolicy): void {
|
|
92
|
+
assertFiniteAuthCount(
|
|
93
|
+
'rateLimit.maxAttempts',
|
|
94
|
+
policy.maxAttempts,
|
|
95
|
+
'`failures.length >= NaN` is false, so the lockout never engages and guessing is unlimited',
|
|
96
|
+
1,
|
|
97
|
+
);
|
|
98
|
+
assertFiniteAuthCount(
|
|
99
|
+
'rateLimit.windowMs',
|
|
100
|
+
policy.windowMs,
|
|
101
|
+
'every recorded failure falls outside the window immediately, so nothing ever accumulates',
|
|
102
|
+
1,
|
|
103
|
+
);
|
|
104
|
+
assertFiniteAuthCount(
|
|
105
|
+
'rateLimit.lockoutMs',
|
|
106
|
+
policy.lockoutMs,
|
|
107
|
+
'`now < lockedUntil` is false, so a lockout that was established holds nobody',
|
|
108
|
+
1,
|
|
109
|
+
);
|
|
110
|
+
if (policy.orgMaxAttempts !== undefined) {
|
|
111
|
+
assertFiniteAuthCount(
|
|
112
|
+
'rateLimit.orgMaxAttempts',
|
|
113
|
+
policy.orgMaxAttempts,
|
|
114
|
+
'the tenant bucket never fills, so one org can saturate the shared limiter',
|
|
115
|
+
1,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (policy.maxKeys !== undefined) {
|
|
119
|
+
assertFiniteAuthCount(
|
|
120
|
+
'rateLimit.maxKeys',
|
|
121
|
+
policy.maxKeys,
|
|
122
|
+
'the in-memory table has no ceiling, so a spray of distinct keys is memory the attacker chooses',
|
|
123
|
+
1,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
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
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)`
|