@ultimat3/auth 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -18,6 +18,18 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
18
18
 
19
19
  - Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
20
20
  parameter to it re-opens account enumeration.
21
+ - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF** (`As of
22
+ 2026-08`). `Bun.password.verify` THROWS rather than answering `false` on an unsupported algorithm
23
+ (a Django `pbkdf2_sha256$…` row: `UnsupportedAlgorithm`) or a malformed PHC string
24
+ (`InvalidEncoding`), so `verifyPassword` catching nothing was two faults at once: a bare `Error`
25
+ out of `login()` — a 500 where `loginFailed()`'s `X_UNAUTHENTICATED` belongs — and an enumeration
26
+ oracle on exactly the rows that have not migrated off the legacy scheme, which is the normal
27
+ state of a table mid-migration. `verifyAgainst` (`password.ts`) answers `null` there, and `null`
28
+ joins the no-user branch, `''` with it. Nothing is logged: the algorithm of an unreadable hash is
29
+ the same oracle one layer down. An `AuthError` out of the gate (`X_OVERLOADED`) is **re-thrown**,
30
+ never folded into the failure — a shed is load, not a verdict. Supported-but-old stays a verdict:
31
+ bcrypt verifies natively and `needsRehash` flags it, which is the rehash-on-login lever a legacy
32
+ migration rewrites rows with, and `password.test.ts` pins both halves.
21
33
  - The limiter's table is **bounded**, and the eviction order is part of the guarantee. `ipKey`
22
34
  mints one entry per source address, so half the keys are attacker-chosen and a spray from an
23
35
  IPv6 /64 is a fresh key per attempt. Every bucket carries `forgetAtMs` — window emptied *and*
@@ -182,6 +194,38 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
182
194
  `auth.limiter` around `verifyTotp` — today it is wired only into `login`, so a six-digit code
183
195
  would be the one credential in this package with no lockout. `TotpReplayGuard` is already built
184
196
  and must be the completion's, not a second one.
197
+ - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true` that reaches it
198
+ anyway** (`X_CONFIG_INVALID`, borrowed from core), `As of 2026-08`. It resolved onto the frozen
199
+ `Auth` and **nothing read it** — `login()` and `signInWithOAuth()` branch on `user.mfaSecret`
200
+ alone and mint `mfaSatisfied: true` otherwise, so `required: true` handed a user who had never
201
+ enrolled a fully-privileged session while reading as "this deployment requires a second factor".
202
+ Enforcing it was the tempting fix and is a **lockout**: `actorFromUser` degrades a session only
203
+ when `mfaSecret !== null`, so an un-enrolled user has no half-authenticated actor to enrol
204
+ through, this package ships no enrolment route to send them to, and `mfaRequired()`'s own `fix:`
205
+ (`verifyTotp({ secret: user.mfaSecret, … })`) cannot be followed with a null secret — a dead
206
+ `fix:`, the defect `oauth-paths.ts` exists to stop. So the unenforceable declaration is refused
207
+ where it is written, exactly as `assertAuthLimiterPolicy` refuses a per-process limiter under a
208
+ fleet-wide lockout. The literal type is the build error and the runtime check is for the JS
209
+ caller and the JSON config the type cannot reach, the split `invariantColumns()`'s Proxy keeps.
210
+ `mfa.issuer` is the half that stayed, and it now has a reader: `enrolTotp(auth, { account })`
211
+ takes the `Auth` every other entry point takes, so the product name an authenticator app shows
212
+ is declared once at `defineAuth` instead of restated at every enrolment.
213
+ - **`createTotpReplayGuard`'s table is bounded, and the eviction ORDER is the guarantee.** It
214
+ pruned steps inside one subject's `Set` and never revisited a subject who stopped signing in, so
215
+ the map carried one permanent entry per user for the life of the process. Evicting a subject
216
+ makes a step they have already spent replayable again, so the order cannot be recency of
217
+ insertion: a subject whose every step has fallen below the drift floor is **forgotten** (nothing
218
+ outside ±drift is ever offered to `verifyTotp`, so that entry answers exactly as a missing one),
219
+ and only if that is not enough does `DEFAULT_MAX_TOTP_SUBJECTS` evict live state, sorted by
220
+ newest spent step ascending with a least-recently-seen tie-break — the subject who just proved a
221
+ code is always the last one out. Same shape as the limiter's "a live lockout outranks its own
222
+ deadline". `remember` re-files its subject so the map's iteration order IS that tie-break.
223
+ `maxSubjects` is **normalised before any of that arithmetic runs** (`boundedSubjects`): the cap
224
+ ran on the caller's number unchecked, so `Infinity` left the table exactly as unbounded as it was
225
+ before it was capped, and `NaN` — every comparison against which is false — made the eviction
226
+ loop's `used.size <= evictTo` never true, emptying the table on the first sweep and handing back
227
+ a replay of the code the subject had just spent. Anything that is not a positive finite integer
228
+ takes `DEFAULT_MAX_TOTP_SUBJECTS`; a fraction still floors.
185
229
  - SAML is out of scope permanently: XML-DSig canonicalisation has no Bun native and would need a
186
230
  real dependency. Put an OIDC-speaking bridge in front and register that.
187
231
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ export const auth = defineAuth({
11
11
  adapter: new BuiltinAdapter(), // or MemoryAdapter, or your Better Auth binding
12
12
  session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
13
13
  password: { minLength: 12 },
14
- mfa: { issuer: 'Acme' },
14
+ mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
15
15
  providers: ['github', 'google'],
16
16
  link: 'verified-email', // the default; `'never'` is the only other value
17
17
  });
@@ -215,10 +215,13 @@ rows, because `AuthAdapter` has no MFA member and adding one would break every t
215
215
  adapter.
216
216
 
217
217
  ```ts
218
+ import type { Auth } from '@ultimat3/auth';
218
219
  import { createTotpReplayGuard, enrolTotp, generateRecoveryCodes, verifyTotp } from '@ultimat3/auth';
219
220
  import { systemClock } from '@ultimat3/core';
220
221
 
221
- const enrolment = enrolTotp({ issuer: 'Acme', account: 'ada@example.com' });
222
+ declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
223
+
224
+ const enrolment = enrolTotp(auth, { account: 'ada@example.com' }); // issuer: auth.mfa.issuer
222
225
  // enrolment.uri -> otpauth://… the QR code
223
226
  // enrolment.secret -> base32, store it against the user
224
227
 
@@ -236,9 +239,9 @@ export function secondFactorHolds(userId: string, secret: string, code: string):
236
239
 
237
240
  | Call | Answers |
238
241
  |---|---|
239
- | `enrolTotp({ issuer, account, secret? })` | `{ secret, uri, digits, periodSeconds }` — `secret` omitted mints one |
242
+ | `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
240
243
  | `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
241
- | `createTotpReplayGuard(drift?)` | the in-process `{ isUsed, remember }`; a fleet passes a Redis-backed pair of the same two methods |
244
+ | `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
242
245
  | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
243
246
  | `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
244
247
  | `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code |
@@ -251,6 +254,24 @@ side of now, so without the guard the same six digits log in twice inside a minu
251
254
  answers `{ ok: false, step }` — the step still named — when `usedSteps` already holds it, which is
252
255
  how a replay is told apart from a wrong code.
253
256
 
257
+ The guard's table is **bounded** (`DEFAULT_MAX_TOTP_SUBJECTS`, 10,000), because a per-subject map
258
+ that only ever grows is one process' lifetime away from an OOM. A subject whose every remembered
259
+ step has fallen below the drift floor is *forgotten* — `verifyTotp` can never offer that step
260
+ again, so the entry answers exactly as a missing one — and only if that is not enough does the cap
261
+ evict live state, furthest from the live window first. The order is the guarantee: evicting a
262
+ subject makes a step they have already spent replayable, so the subject who just authenticated is
263
+ always the last one out.
264
+
265
+ **`mfa.required` is accepted only as `false`, and that is deliberate.** The field exists and is
266
+ typed as the literal `false`, so `required: true` is a compile error; a `true` that reaches
267
+ `defineAuth` from JavaScript or from JSON — where the type cannot — is refused at boot with
268
+ `X_CONFIG_INVALID` naming the key, never an unknown-key error and never a silent accept. Nothing
269
+ read it: both credential paths branch on `user.mfaSecret` alone, so an un-enrolled user was handed
270
+ a full session under a config that read as "this deployment requires a second factor". Enforcing it
271
+ at `login()` instead is a lockout — `actorFromUser` degrades only a user who HAS a secret, and this
272
+ package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
273
+ `if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
274
+
254
275
  **The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
255
276
  throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
256
277
  `createSession({ mfaSatisfied: true })` in the app's own route. The framework ships no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,8 +31,8 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "2.0.0",
35
- "@ultimat3/db": "2.0.0",
36
- "@ultimat3/schema": "2.0.0"
34
+ "@ultimat3/core": "3.0.0",
35
+ "@ultimat3/db": "3.0.0",
36
+ "@ultimat3/schema": "3.0.0"
37
37
  }
38
38
  }
package/src/auth.ts CHANGED
@@ -7,7 +7,7 @@ import { type Clock, systemClock, uuid } from '@ultimat3/core';
7
7
  import { t } from '@ultimat3/schema';
8
8
  import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
9
9
  import { normaliseEmail } from './email';
10
- import { mfaRequired, sessionUnknown } from './errors';
10
+ import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
11
11
  import type { OAuthProviderId } from './oauth';
12
12
  import { oauthProviderIds } from './oauth-registry';
13
13
  import {
@@ -106,10 +106,25 @@ export const VerificationSchema = t.object({
106
106
  */
107
107
  export type OAuthLinkPolicy = 'verified-email' | 'never';
108
108
 
109
+ /** The product name an authenticator app shows when the app declared none. */
110
+ export const DEFAULT_MFA_ISSUER = 'Ultimate';
111
+
109
112
  export interface AuthMfaPolicy {
110
- /** Shown in the authenticator app. Usually the product name. */
113
+ /**
114
+ * Shown in the authenticator app. Usually the product name, and `enrolTotp(auth, …)` reads it
115
+ * from here so it is written once — a call may still name its own for the one enrolment.
116
+ */
111
117
  readonly issuer: string;
112
- readonly required: boolean;
118
+ /**
119
+ * The literal `false`, so `required: true` is a type error rather than a comment — the shape
120
+ * `OAuthProvider.usesPkce` has, and for the same reason: the value nothing enforces has to be
121
+ * unrepresentable, not discouraged. This package cannot make a second factor mandatory. Both
122
+ * credential paths branch on `user.mfaSecret` alone and `actorFromUser` degrades only a user
123
+ * who HAS a secret, so a user who never enrolled has no half-authenticated actor to enrol
124
+ * through and no enrolment route to reach — refusing them at `login()` locks them out for good.
125
+ * `defineAuth` refuses the declaration outright; the app gates its own sign-in handler.
126
+ */
127
+ readonly required: false;
113
128
  }
114
129
 
115
130
  export interface AuthConfigInput {
@@ -166,6 +181,13 @@ export function defineAuth(config: AuthConfigInput): Auth {
166
181
  const orgLimits = orgRateLimit(rateLimit);
167
182
  const orgLimiter = config.orgLimiter ?? createAuthLimiter(clock, orgLimits);
168
183
  if (config.orgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, config.orgLimiter);
184
+ // Read through a widened local on purpose: the field's type is the literal `false`, so this
185
+ // branch is unreachable from TypeScript and reachable from every JS caller and every config
186
+ // parsed out of JSON — the same split `invariantColumns()` keeps its Proxy behind a compile
187
+ // error for. A declaration this package cannot enforce is refused where it is written.
188
+ const declaredMfa: { readonly required?: unknown } = config.mfa ?? {};
189
+ if (declaredMfa.required === true) throw mfaRequiredUnenforceable();
190
+ const mfa: AuthMfaPolicy = { issuer: config.mfa?.issuer ?? DEFAULT_MFA_ISSUER, required: false };
169
191
  return Object.freeze({
170
192
  adapter: config.adapter,
171
193
  clock,
@@ -177,7 +199,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
177
199
  // exactly when the app declared 'shared' and left this limiter to the default.
178
200
  orgRateLimit: orgLimiter.policy,
179
201
  orgLimiter,
180
- mfa: { issuer: config.mfa?.issuer ?? 'Ultimate', required: config.mfa?.required ?? false },
202
+ mfa,
181
203
  providers: config.providers ?? oauthProviderIds(),
182
204
  link: config.link ?? 'verified-email',
183
205
  });
package/src/errors.ts CHANGED
@@ -174,6 +174,26 @@ export const mfaRequired = (userId: string): AuthError =>
174
174
  meta: { userId },
175
175
  });
176
176
 
177
+ /**
178
+ * `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
179
+ * security guarantee — "this deployment requires a second factor" — and nothing in this package
180
+ * could make it true: `login()` and `signInWithOAuth()` branch on `user.mfaSecret` alone, so a
181
+ * user who never enrolled was handed a fully-privileged session under it, and `actorFromUser`
182
+ * strips privileges only for a user who HAS a secret, so there is no half-authenticated actor to
183
+ * hand them instead. Enforcing it inside `login()` would refuse exactly the people with nothing to
184
+ * offer, with no enrolment route shipped to send them to — a permanent lockout, not a second
185
+ * factor. So the declaration is refused at boot, exactly as `assertAuthLimiterPolicy` refuses a
186
+ * per-process limiter under a fleet-wide lockout: a guarantee this package cannot show holds is
187
+ * never assumed. `X_CONFIG_INVALID` is core's code, borrowed rather than re-declared.
188
+ */
189
+ export const mfaRequiredUnenforceable = (): AuthError =>
190
+ new AuthError({
191
+ code: 'X_CONFIG_INVALID',
192
+ cause:
193
+ 'defineAuth({ mfa: { required: true } }) declares a second factor this package does not enforce: login() mints a full session for any user whose mfaSecret is null',
194
+ 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, ...)',
195
+ });
196
+
177
197
  /**
178
198
  * The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
179
199
  * line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export type {
41
41
  export {
42
42
  AccountSchema,
43
43
  authenticate,
44
+ DEFAULT_MFA_ISSUER,
44
45
  defineAuth,
45
46
  login,
46
47
  logout,
@@ -72,6 +73,7 @@ export {
72
73
  forbidden,
73
74
  kdfOverloaded,
74
75
  mfaRequired,
76
+ mfaRequiredUnenforceable,
75
77
  oauthAccountNotLinked,
76
78
  oauthDenied,
77
79
  oauthExchangeFailed,
@@ -120,6 +122,7 @@ export {
120
122
  export { MemoryAdapter } from './memory-adapter';
121
123
  export type {
122
124
  EnrolTotpInput,
125
+ MemoryTotpReplayGuard,
123
126
  RecoveryCodeSet,
124
127
  TotpEnrolment,
125
128
  TotpReplayGuard,
@@ -130,6 +133,7 @@ export {
130
133
  base32Decode,
131
134
  base32Encode,
132
135
  createTotpReplayGuard,
136
+ DEFAULT_MAX_TOTP_SUBJECTS,
133
137
  enrolTotp,
134
138
  generateRecoveryCodes,
135
139
  generateTotpSecret,
package/src/mfa.ts CHANGED
@@ -3,6 +3,7 @@
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 type { Auth } from './auth';
6
7
  import { randomBytes, sha256Hex, timingSafeEqual } from './tokens';
7
8
 
8
9
  const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
@@ -63,17 +64,28 @@ export interface TotpEnrolment {
63
64
  }
64
65
 
65
66
  export interface EnrolTotpInput {
66
- readonly issuer: string;
67
+ /**
68
+ * Omitted, the issuer is `auth.mfa.issuer` — one declaration, at `defineAuth`, so the product
69
+ * name an authenticator app shows is not restated at every enrolment. Named here only when one
70
+ * call needs a different one (a separate admin console entry, say).
71
+ */
72
+ readonly issuer?: string | undefined;
67
73
  readonly account: string;
68
74
  readonly secret?: string | undefined;
69
75
  }
70
76
 
71
- export function enrolTotp(input: EnrolTotpInput): TotpEnrolment {
77
+ /**
78
+ * Takes the `Auth` every other entry point in this package takes, and for the same reason: the
79
+ * issuer is configuration, and a pure function that could not read the configuration is what made
80
+ * `defineAuth({ mfa: { issuer } })` a string the framework wrote down and never read.
81
+ */
82
+ export function enrolTotp(auth: Auth, input: EnrolTotpInput): TotpEnrolment {
72
83
  const secret = input.secret ?? generateTotpSecret();
73
- const label = `${encodeURIComponent(input.issuer)}:${encodeURIComponent(input.account)}`;
84
+ const issuer = input.issuer ?? auth.mfa.issuer;
85
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(input.account)}`;
74
86
  const query = new URLSearchParams({
75
87
  secret,
76
- issuer: input.issuer,
88
+ issuer,
77
89
  algorithm: 'SHA1',
78
90
  digits: String(TOTP_DIGITS),
79
91
  period: String(TOTP_STEP_SECONDS),
@@ -148,23 +160,106 @@ export interface TotpReplayGuard {
148
160
  remember(subject: string, step: number, at: Date): void;
149
161
  }
150
162
 
163
+ /** What `createTotpReplayGuard` returns: the interface, plus the bound it keeps, observable. */
164
+ export interface MemoryTotpReplayGuard extends TotpReplayGuard {
165
+ readonly size: number;
166
+ }
167
+
168
+ /**
169
+ * Hard bound on tracked subjects. One entry is one user who has completed a TOTP check inside the
170
+ * last drift window, so the natural cardinality is far below this — the cap is the backstop, the
171
+ * same one `DEFAULT_MAX_AUTH_LIMIT_KEYS` is for the limiter's table.
172
+ */
173
+ export const DEFAULT_MAX_TOTP_SUBJECTS = 10_000;
174
+
175
+ /** An idle guard still sweeps this often, so one burst's subjects do not sit until the next. */
176
+ const SWEEP_EVERY_STEPS = 2;
177
+
178
+ /**
179
+ * The cap arithmetic ran on the caller's number unchecked, and the two values a misread config
180
+ * hands you each defeated the bound in their own way: `Infinity` makes `used.size > cap` never
181
+ * true, so the table is exactly as unbounded as before it was capped; `NaN` makes EVERY comparison
182
+ * false, so `used.size <= evictTo` never stops the eviction loop and one sweep empties the table —
183
+ * including the subject who just authenticated, whose step is then replayable. Anything that is
184
+ * not a positive finite integer is a config the caller did not mean, so it takes the default
185
+ * rather than a bound derived from it. A fraction still floors: `2.5` is a caller who meant 2.
186
+ */
187
+ function boundedSubjects(maxSubjects: number): number {
188
+ if (!Number.isFinite(maxSubjects) || maxSubjects < 1) return DEFAULT_MAX_TOTP_SUBJECTS;
189
+ return Math.floor(maxSubjects);
190
+ }
191
+
192
+ /** The last step this subject has spent — how close their entry still is to the live window. */
193
+ const newestStep = (steps: ReadonlySet<number>): number => {
194
+ let newest = Number.NEGATIVE_INFINITY;
195
+ for (const step of steps) newest = Math.max(newest, step);
196
+ return newest;
197
+ };
198
+
151
199
  /**
152
200
  * In-memory by default because a single web process is the common case; a multi-process
153
201
  * deployment passes a Redis-backed guard with the same two methods. Steps older than the
154
202
  * drift window are dropped — nothing outside it can be replayed anyway.
203
+ *
204
+ * Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
205
+ * and never revisited a subject who stopped signing in, so the table carried one permanent entry
206
+ * per user for the life of the process. Two rules keep it flat, and the ORDER is the guarantee —
207
+ * evicting a subject makes a step they have already spent replayable again, so it may never be
208
+ * the subject who just authenticated. A subject whose every step has fallen below the drift floor
209
+ * is *forgotten*, not evicted: `verifyTotp` only ever offers a step within ±drift of now, so that
210
+ * entry answers exactly as a missing one and dropping it changes no decision. Only if forgetting
211
+ * is not enough does the cap evict live state, furthest from the live window first — the shape
212
+ * `createAuthLimiter` evicts by, where a live lockout is the last bucket to go.
155
213
  */
156
- export function createTotpReplayGuard(drift: number = TOTP_DRIFT_STEPS): TotpReplayGuard {
214
+ export function createTotpReplayGuard(
215
+ drift: number = TOTP_DRIFT_STEPS,
216
+ maxSubjects: number = DEFAULT_MAX_TOTP_SUBJECTS,
217
+ ): MemoryTotpReplayGuard {
157
218
  const used = new Map<string, Set<number>>();
219
+ const cap = boundedSubjects(maxSubjects);
220
+ // Batched down to 90% of the cap so the sort below is paid once per 10% of it, not per check.
221
+ const evictTo = Math.max(1, Math.floor(cap * 0.9));
222
+ let lastSweepStep = Number.NEGATIVE_INFINITY;
223
+
224
+ const prune = (steps: Set<number>, floor: number): void => {
225
+ for (const known of steps) {
226
+ if (known < floor) steps.delete(known);
227
+ }
228
+ };
229
+
230
+ const sweep = (now: number, floor: number): void => {
231
+ lastSweepStep = now;
232
+ for (const [subject, steps] of used) {
233
+ prune(steps, floor);
234
+ if (steps.size === 0) used.delete(subject);
235
+ }
236
+ if (used.size <= cap) return;
237
+ // Map iteration is insertion order and `remember` re-files the subject it touches, so this
238
+ // sort — stable by specification — breaks a tie on the newest step by least recently seen.
239
+ // Both keys point the same way: the subject who just proved a code is the last one out.
240
+ const furthest = [...used.entries()].sort((a, b) => newestStep(a[1]) - newestStep(b[1]));
241
+ for (const [subject] of furthest) {
242
+ if (used.size <= evictTo) break;
243
+ used.delete(subject);
244
+ }
245
+ };
246
+
158
247
  return {
248
+ get size() {
249
+ return used.size;
250
+ },
159
251
  isUsed: (subject, step) => used.get(subject)?.has(step) === true,
160
252
  remember: (subject, step, at) => {
253
+ const now = totpStep(at);
254
+ const floor = now - drift;
161
255
  const steps = used.get(subject) ?? new Set<number>();
162
- const floor = totpStep(at) - drift;
163
- for (const known of steps) {
164
- if (known < floor) steps.delete(known);
165
- }
256
+ prune(steps, floor);
166
257
  steps.add(step);
258
+ // Deleted before it is set, so this subject moves to the back of the iteration order and
259
+ // that order is least-recently-remembered first. Nothing else observes it.
260
+ used.delete(subject);
167
261
  used.set(subject, steps);
262
+ if (used.size > cap || now - lastSweepStep >= SWEEP_EVERY_STEPS) sweep(now, floor);
168
263
  },
169
264
  };
170
265
  }
package/src/password.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // a migration. Verification always burns a full KDF even when the user does not exist —
4
4
  // otherwise response time answers "is this email registered?" for free.
5
5
 
6
- import { passwordWeak } from './errors';
6
+ import { AuthError, passwordWeak } from './errors';
7
7
  import { kdfGate } from './kdf-gate';
8
8
 
9
9
  export interface PasswordParams {
@@ -100,12 +100,51 @@ export function needsRehash(
100
100
  }
101
101
 
102
102
  export interface VerifyPasswordInput {
103
- /** `null` when no user matched. The KDF still runs, on a throwaway hash. */
103
+ /**
104
+ * `null` when no user matched. The KDF still runs, on a throwaway hash — and a stored hash Bun
105
+ * cannot read takes that same branch, so neither is separable from a wrong password.
106
+ */
104
107
  readonly hash: string | null;
105
108
  readonly password: string;
106
109
  readonly params?: PasswordParams | undefined;
107
110
  }
108
111
 
112
+ /** The KDF the happy path would have burnt, then the one failure shape. Never a cheap answer. */
113
+ async function burnAndFail(
114
+ password: string,
115
+ params: PasswordParams,
116
+ ): Promise<PasswordVerification> {
117
+ await hashPassword(password, params);
118
+ return FAILED;
119
+ }
120
+
121
+ /**
122
+ * `false` is a wrong password, `null` is a stored hash Bun cannot read at all.
123
+ *
124
+ * `Bun.password.verify` THROWS rather than answering on a hash it cannot parse — measured, bun
125
+ * 1.3.14: a Django `pbkdf2_sha256$...` row is `UnsupportedAlgorithm`, a truncated bcrypt string is
126
+ * `InvalidEncoding`. Letting that escape was two faults. A bare `Error` reached `login()`, so the
127
+ * caller answered 500 instead of the one credential failure; and it landed on exactly the rows
128
+ * that have not migrated off the legacy scheme, which makes "has this account been migrated" —
129
+ * and therefore "does this account exist" — readable from the outside. That is the enumeration
130
+ * oracle the whole file is built to close, on the one table where a foreign hash is normal.
131
+ *
132
+ * Supported-but-old is a different thing and stays a verdict: bcrypt verifies natively here and
133
+ * `needsRehash` flags it, which is the lever a legacy migration rewrites rows with.
134
+ *
135
+ * Nothing is logged. The algorithm of an unreadable hash is the oracle again, one layer down.
136
+ */
137
+ async function verifyAgainst(password: string, hash: string): Promise<boolean | null> {
138
+ try {
139
+ return await kdfGate().run(async () => await Bun.password.verify(password, hash));
140
+ } catch (error) {
141
+ // The gate shedding (`X_OVERLOADED`) is load, never a verdict on the credential: swallowing it
142
+ // would answer "wrong password" for a request this process refused to do the work for.
143
+ if (error instanceof AuthError) throw error;
144
+ return null;
145
+ }
146
+ }
147
+
109
148
  /**
110
149
  * Never short-circuits on a missing user: the `hashPassword` call in the `null` branch costs
111
150
  * the same order of magnitude as the verify in the happy branch, so the two are not separable
@@ -113,13 +152,14 @@ export interface VerifyPasswordInput {
113
152
  */
114
153
  export async function verifyPassword(input: VerifyPasswordInput): Promise<PasswordVerification> {
115
154
  const params = input.params ?? DEFAULT_PASSWORD_PARAMS;
116
- // Read into a local so the closure below narrows without a cast.
155
+ // Read into a local so the two branches below narrow it without a cast.
117
156
  const hash = input.hash;
118
- if (hash === null) {
119
- await hashPassword(input.password, params);
120
- return FAILED;
121
- }
122
- const ok = await kdfGate().run(async () => await Bun.password.verify(input.password, hash));
157
+ // `''` joins the no-user branch rather than reaching the KDF: an account with no password
158
+ // credential (oauth-only) answers `false` from Bun for free, and a failure that costs nothing
159
+ // is a stopwatch away from "this address exists but has never set a password".
160
+ if (hash === null || hash === '') return await burnAndFail(input.password, params);
161
+ const ok = await verifyAgainst(input.password, hash);
162
+ if (ok === null) return await burnAndFail(input.password, params);
123
163
  if (!ok) return FAILED;
124
164
  return { ok: true, needsRehash: needsRehash(hash, params) };
125
165
  }