@ultimat3/auth 23.0.0 → 24.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/src/errors.ts CHANGED
@@ -29,6 +29,7 @@ export const AUTH_OWNED_ERROR_CODES = [
29
29
  'X_AUTH_WRITE_FAILED',
30
30
  'X_AUTH_LIMITER_NOT_SHARED',
31
31
  'X_AUTH_LIMITER_POLICY_MISMATCH',
32
+ 'X_MFA_SECRET_UNSEALED',
32
33
  ] as const;
33
34
 
34
35
  /**
@@ -74,6 +75,7 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
74
75
  X_AUTH_WRITE_FAILED: 'an adapter write returned no row, so it cannot be confirmed',
75
76
  X_AUTH_LIMITER_NOT_SHARED: 'the lockout is declared fleet-wide and the limiter is per-process',
76
77
  X_AUTH_LIMITER_POLICY_MISMATCH: 'the limiter in use enforces other numbers than the app declared',
78
+ X_MFA_SECRET_UNSEALED: 'a stored totp secret is not sealed, so it is never read as one',
77
79
  };
78
80
 
79
81
  // Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
@@ -162,23 +164,41 @@ export const kdfOverloaded = (active: number, queued: number): AuthError =>
162
164
  });
163
165
 
164
166
  /**
165
- * The second leg is the APP's, and this line says so — it named `POST /auth/mfa/verify` for a
166
- * release while no such route, no `completeMfa()` and no pending-MFA credential existed anywhere,
167
- * the same dead-`fix:` defect `oauth-paths.ts` exists to stop. Shipping that route from here would
168
- * be worse than saying nothing: the only correlation value this error carries is a user id, so the
169
- * route would be unauthenticated by construction and MFA would become the ONLY factor. The design
170
- * constraint for the real second leg is in `packages/auth/CLAUDE.md`.
167
+ * The second leg is `completeMfa(auth, challenge, code)` (`mfa-challenge.ts`), and the only thing
168
+ * that correlates it with a passed first leg is `meta.challenge`: a sealed, short-lived value the
169
+ * sign-in handler carries to the caller and back. It replaces a bare user id, which made any
170
+ * second leg built on it unauthenticated by construction.
171
171
  *
172
- * `userId` is `meta`, never `cause`: both surfaces that render this code to an anonymous caller
173
- * (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause` and drop
174
- * `meta`, and a user id handed to whoever typed the URL is what feeds the attack above.
172
+ * The challenge is `meta`, never `cause`: both surfaces that render this code to an anonymous
173
+ * caller (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause`
174
+ * and drop `meta`, so the handler decides where it travels — a cookie or its own response body.
175
175
  */
176
- export const mfaRequired = (userId: string): AuthError =>
176
+ export const mfaRequired = (challenge: string): AuthError =>
177
177
  new AuthError({
178
178
  code: 'X_MFA_REQUIRED',
179
179
  cause: 'this account has TOTP enrolled and the second factor has not been satisfied',
180
- fix: 'no second-factor route ships yet — catch X_MFA_REQUIRED in your sign-in handler, check the code with verifyTotp({ secret: user.mfaSecret, code, at }), then mint the session with createSession(auth.sessions, { userId, mfaSatisfied: true })',
181
- meta: { userId },
180
+ fix: 'catch X_MFA_REQUIRED in your sign-in handler, hand error.meta.challenge to the caller, and finish with: await completeMfa(auth, challenge, code)',
181
+ meta: { challenge },
182
+ });
183
+
184
+ /**
185
+ * A challenge `completeMfa` will not act on: not one this app sealed, past its expiry, or naming
186
+ * an account that can no longer finish (deleted, disabled, or no longer enrolled). One refusal
187
+ * for all of them — which it was is not the caller's to learn.
188
+ */
189
+ export const mfaChallengeInvalid = (): AuthError =>
190
+ new AuthError({
191
+ code: 'X_UNAUTHENTICATED',
192
+ cause: 'the second-factor challenge is not one this app issued, or it is no longer usable',
193
+ fix: 'sign in again for a fresh challenge: await login(auth, { email, password })',
194
+ });
195
+
196
+ /** A wrong or already-spent code. The attempt it cost stays counted against the account. */
197
+ export const mfaCodeRejected = (): AuthError =>
198
+ new AuthError({
199
+ code: 'X_UNAUTHENTICATED',
200
+ cause: 'the second-factor code did not match, or it was already used',
201
+ fix: 'read the current code from the authenticator app and retry: await completeMfa(auth, challenge, code)',
182
202
  });
183
203
 
184
204
  /**
@@ -201,6 +221,19 @@ export const mfaSecretInvalid = (surface: string): AuthError =>
201
221
  fix: 'issue a fresh one and store it: const { secret } = enrolTotp(auth, { account: user.email }) — an imported secret must be base32 (A-Z and 2-7, padding, spaces and dashes ignored) and decode to at least one byte',
202
222
  });
203
223
 
224
+ /**
225
+ * A stored secret that is not sealed. It is refused rather than read: the plaintext path is the
226
+ * thing sealing removes, and a reader that fell back to it would keep it alive for good. The fix
227
+ * is the one-shot that seals every such row; `x doctor` reports the count before a user does.
228
+ */
229
+ export const mfaSecretUnsealed = (): AuthError =>
230
+ new AuthError({
231
+ code: 'X_MFA_SECRET_UNSEALED',
232
+ cause:
233
+ 'x_users.mfa_secret holds a value that is not sealed — written before second-factor secrets were sealed at rest, or written past saveTotpSecret — and a plaintext secret is never read as one',
234
+ fix: 'x auth seal-mfa --json',
235
+ });
236
+
204
237
  /**
205
238
  * `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
206
239
  * security guarantee — "this deployment requires a second factor" — and nothing in this package
@@ -246,23 +279,10 @@ export const authPolicyNumberInvalid = (
246
279
  meta: { option: key, value: String(value) },
247
280
  });
248
281
 
249
- /**
250
- * An explicit key for `clientScopeOf` under the floor `SESSION_SECRET` is held to. The env path
251
- * already refused a short secret; the option skipped the screen, so `{ secret: 'x' }` keyed every
252
- * page's scope id with one character. `X_CONFIG_INVALID`, borrowed like the two above.
253
- */
254
- export const clientScopeSecretShort = (length: number, minLength: number): AuthError =>
255
- new AuthError({
256
- code: 'X_CONFIG_INVALID',
257
- cause: `clientScopeOf({ secret }) was passed a key of ${String(length)} characters and at least ${String(minLength)} are required, so the scope id would be keyed by a guessable secret`,
258
- fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
259
- meta: { option: 'secret', minLength },
260
- });
261
-
262
282
  /**
263
283
  * An api key `env` the parser cannot read back. `parseApiKey` splits `ult_<env>_<id>_<secret>` on
264
284
  * `_`, so `issueApiKey({ env: 'live_eu' })` minted a key its own verifier refused. Refused at
265
- * issue — `X_CONFIG_INVALID`, borrowed like the three above — never at first use.
285
+ * issue — `X_CONFIG_INVALID`, borrowed like the two above — never at first use.
266
286
  */
267
287
  export const apiKeyEnvInvalid = (env: string): AuthError =>
268
288
  new AuthError({
@@ -274,6 +294,19 @@ export const apiKeyEnvInvalid = (env: string): AuthError =>
274
294
  meta: { option: 'env' },
275
295
  });
276
296
 
297
+ /**
298
+ * A wildcard scope on an api key. `*` and `<resource>:*` are how a ROLE is granted everything; on
299
+ * a key they are a credential that silently grows with every permission the app adds later.
300
+ * Refused at issue — `X_CONFIG_INVALID`, borrowed like the ones above.
301
+ */
302
+ export const apiKeyScopeWildcard = (scope: string): AuthError =>
303
+ new AuthError({
304
+ code: 'X_CONFIG_INVALID',
305
+ cause: `api key scope ${renderCauseValue(scope)} is a wildcard, and a key carries exactly the permissions it names`,
306
+ fix: "pass each permission by name to issueApiKey({ scopes }) — e.g. scopes: ['post:read', 'post:publish']",
307
+ meta: { option: 'scopes' },
308
+ });
309
+
277
310
  export const passwordWeak = (reasons: readonly string[]): AuthError =>
278
311
  new AuthError({
279
312
  code: 'X_PASSWORD_WEAK',
@@ -281,28 +314,35 @@ export const passwordWeak = (reasons: readonly string[]): AuthError =>
281
314
  fix: 'choose a longer, uncommon password — or relax defineAuth({ password: { minLength } })',
282
315
  });
283
316
 
317
+ /** The tenant bucket's key prefix. Declared here because the refusal below must recognise it. */
318
+ export const ORG_KEY_PREFIX = 'org:';
319
+
284
320
  /**
285
321
  * The escape is `recordSuccess(key)`, which is what a successful login already calls: it deletes
286
322
  * the bucket, so it clears exactly this one key and nothing else. `reset()` exists too and is the
287
323
  * wrong reach — it drops every bucket in the table, including the spray this lockout is holding.
288
324
  *
289
- * `key` carries an address or an email the caller chose, so it goes through `renderFixLiteral`:
290
- * a fix line has to still parse after a hostile value lands in it.
325
+ * An `ip:` or `account:` key is the caller's own input, so it is echoed (escaped). A tenant key is
326
+ * NOT: the org id was looked up from the address the caller typed, and naming it — or even saying
327
+ * "an organisation" — confirms to an anonymous caller that the address belongs to a member.
291
328
  */
292
- export const accountLocked = (key: string, retryAfterSeconds: number): AuthError =>
293
- new AuthError({
329
+ export const accountLocked = (key: string, retryAfterSeconds: number): AuthError => {
330
+ const tenant = key.startsWith(ORG_KEY_PREFIX);
331
+ return new AuthError({
294
332
  code: 'X_ACCOUNT_LOCKED',
295
- // `renderCauseValue`, matching `oauthDenied`: `ipKey(ip)` builds this key from whatever address
296
- // string its caller passed, so a newline in it writes a second log line an operator reads as
297
- // genuine. The value is `string` by type — the static scan only sees `unknown`/`any`, so this
298
- // one was never going to be caught for us — and rendering it as a JSON string literal is
299
- // escaping, not throw-safety.
300
- cause: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
301
- fix: `wait ${retryAfterSeconds}s — or clear this one bucket: auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}), auth.orgLimiter for an org: key — or raise defineAuth({ rateLimit })`,
333
+ // `renderCauseValue`: `ipKey(ip)` builds the key from whatever address string its caller
334
+ // passed, so a newline in it would write a second log line an operator reads as genuine.
335
+ cause: tenant
336
+ ? `sign-in is locked out for another ${retryAfterSeconds}s after repeated failures`
337
+ : `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
338
+ fix: tenant
339
+ ? `wait ${retryAfterSeconds}s — or clear the one bucket that is holding: await auth.orgLimiter.recordSuccess(orgKey(user.orgId)) — or raise defineAuth({ rateLimit })`
340
+ : `wait ${retryAfterSeconds}s — or clear this one bucket: await auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}) — or raise defineAuth({ rateLimit })`,
302
341
  // `kdfOverloaded`'s shape: `@ultimat3/http`'s `retryAfterOf` reads exactly this field. `key`
303
342
  // stays out — `cause`/`fix` escape it on purpose and `meta` is read by surfaces that do not.
304
343
  meta: { retryAfterSeconds },
305
344
  });
345
+ };
306
346
 
307
347
  /** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
308
348
  export const apiKeyInvalid = (): AuthError =>
@@ -376,11 +416,3 @@ export const authLimiterPolicyMismatch = (
376
416
  fix: `construct the limiter with ${field}: ${declared} — defineAuth({ rateLimit, limiter }) compares the two, and the declaration is what Auth.rateLimit reports`,
377
417
  meta: { field, declared, enforced },
378
418
  });
379
-
380
- /** For a custom `AuthAdapter` that implements part of the seam. Nothing shipped throws it. */
381
- export const authNotImplemented = (feature: string, fix: string): AuthError =>
382
- new AuthError({
383
- code: 'X_NOT_IMPLEMENTED',
384
- cause: `${feature} is not implemented by the built-in driver`,
385
- fix,
386
- });
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Single responsibility: the public API of @ultimat3/auth. Explicit named exports only — this
2
2
  // list is what the http pipeline, the MCP surface and generated apps are allowed to depend on.
3
3
 
4
+ export type { PgExecutor } from '@ultimat3/core';
4
5
  export type {
5
6
  AccountStore,
6
7
  ApiKeyStore,
@@ -13,17 +14,27 @@ export type {
13
14
  CreateUserInput,
14
15
  SessionPatch,
15
16
  SessionStore,
17
+ StoredMfaSecret,
16
18
  UserPatch,
17
19
  UserStore,
18
20
  VerificationStore,
19
21
  } from './adapter';
20
22
  export type { ApiKeyCaller, ApiKeyResolverOptions } from './api-key-resolver';
21
23
  export { apiKeyResolver } from './api-key-resolver';
22
- export type { ApiKeySummary, IssueApiKeyInput, IssuedApiKey, ParsedApiKey } from './api-keys';
24
+ export type {
25
+ ApiKeyActorOptions,
26
+ ApiKeySummary,
27
+ ApiKeyVerifyStore,
28
+ IssueApiKeyInput,
29
+ IssuedApiKey,
30
+ ParsedApiKey,
31
+ VerifiedApiKey,
32
+ } from './api-keys';
23
33
  export {
24
34
  API_KEY_NAMESPACE,
25
35
  apiKeyActor,
26
36
  describeApiKey,
37
+ directGrants,
27
38
  issueApiKey,
28
39
  parseApiKey,
29
40
  revokeApiKey,
@@ -50,7 +61,6 @@ export {
50
61
  UserSchema,
51
62
  VerificationSchema,
52
63
  } from './auth';
53
-
54
64
  export { BuiltinAdapter } from './builtin-adapter';
55
65
  /** The opaque per-principal id a per-request document hands the page's client store. */
56
66
  export type { ClientScopeOptions } from './client-scope';
@@ -70,14 +80,13 @@ export {
70
80
  apiKeyInvalid,
71
81
  authLimiterNotShared,
72
82
  authLimiterPolicyMismatch,
73
- authNotImplemented,
74
83
  authUniqueViolation,
75
84
  authWriteFailed,
76
85
  forbidden,
77
86
  kdfOverloaded,
78
- mfaRequired,
79
87
  mfaRequiredUnenforceable,
80
88
  mfaSecretInvalid,
89
+ mfaSecretUnsealed,
81
90
  passwordWeak,
82
91
  sessionExpired,
83
92
  sessionUnknown,
@@ -132,7 +141,7 @@ export {
132
141
  enrolTotp,
133
142
  generateRecoveryCodes,
134
143
  generateTotpSecret,
135
- redeemRecoveryCode,
144
+ recoveryCodeHash,
136
145
  TOTP_DIGITS,
137
146
  TOTP_DRIFT_STEPS,
138
147
  TOTP_STEP_SECONDS,
@@ -140,6 +149,23 @@ export {
140
149
  totpStep,
141
150
  verifyTotp,
142
151
  } from './mfa';
152
+ export type { CompleteMfaOptions } from './mfa-challenge';
153
+ // `mfaRequired` itself is deliberately absent: the only X_MFA_REQUIRED worth throwing carries a
154
+ // challenge this package sealed, and `mfaChallengeRequired` is what seals one.
155
+ export {
156
+ completeMfa,
157
+ MFA_CHALLENGE_PURPOSE,
158
+ MFA_CHALLENGE_TTL_MS,
159
+ mfaChallengeRequired,
160
+ } from './mfa-challenge';
161
+ export type { MfaSecretStore, SealMfaSecretsReport } from './mfa-secret';
162
+ export {
163
+ countUnsealedMfaSecrets,
164
+ MFA_SECRET_PURPOSE,
165
+ openTotpSecret,
166
+ saveTotpSecret,
167
+ sealMfaSecrets,
168
+ } from './mfa-secret';
143
169
  export type {
144
170
  BeginOAuthInput,
145
171
  OAuthCallback,
@@ -169,16 +195,11 @@ export {
169
195
  } from './oauth-cookie';
170
196
  export type { DiscoverOAuthProviderInput } from './oauth-discovery';
171
197
  export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
172
- // The OAuth half of the same contract, split out of `errors.ts` at the 500-line ceiling. Every
173
- // name below was exported from `./errors` before the split and is exported here after it.
198
+ // The OAuth refusals more than one OAuth module raises. A refusal with one thrower is exported
199
+ // from that module, below.
174
200
  export type { OAuthExchangeFailure } from './oauth-errors';
175
201
  export {
176
- emailVerifiedNotStored,
177
- oauthAccountNotLinked,
178
- oauthDenied,
179
202
  oauthExchangeFailed,
180
- oauthLinkingDisabled,
181
- oauthProviderDuplicate,
182
203
  oauthProviderUnknown,
183
204
  oauthStateInvalid,
184
205
  oauthTokenInvalid,
@@ -197,7 +218,13 @@ export type {
197
218
  OAuthSignInInput,
198
219
  ResolveOAuthGrants,
199
220
  } from './oauth-login';
200
- export { completeOAuthLogin, signInWithOAuth } from './oauth-login';
221
+ export {
222
+ completeOAuthLogin,
223
+ emailVerifiedNotStored,
224
+ oauthAccountNotLinked,
225
+ oauthLinkingDisabled,
226
+ signInWithOAuth,
227
+ } from './oauth-login';
201
228
  export {
202
229
  OAUTH_BASE_PATH,
203
230
  OAUTH_CALLBACK_ROUTE_PATH,
@@ -209,12 +236,13 @@ export type { OAuthProfile, OAuthProfileOptions } from './oauth-profile';
209
236
  export { oauthProfile } from './oauth-profile';
210
237
  export {
211
238
  hasOAuthProvider,
239
+ oauthProviderDuplicate,
212
240
  oauthProviderIds,
213
241
  providerFor,
214
242
  registerOAuthProvider,
215
243
  } from './oauth-registry';
216
244
  export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
217
- export { OAUTH_ROUTE_STATUS, oauthLogin } from './oauth-route';
245
+ export { OAUTH_ROUTE_STATUS, oauthDenied, oauthLogin } from './oauth-route';
218
246
  export type {
219
247
  PasswordParams,
220
248
  PasswordPolicy,
@@ -240,6 +268,8 @@ export {
240
268
  actorFromApiKey,
241
269
  actorFromService,
242
270
  actorFromUser,
271
+ apiKeyScopes,
272
+ isWildcardScope,
243
273
  resolveActor,
244
274
  } from './policy-bridge';
245
275
  export type { UpdatePrivilegesResult } from './privileges';
@@ -248,6 +278,7 @@ export type {
248
278
  AuthLimiter,
249
279
  AuthLimiterScope,
250
280
  AuthRateLimitPolicy,
281
+ AuthReservation,
251
282
  MemoryAuthLimiter,
252
283
  } from './rate-limit';
253
284
  export {
@@ -261,11 +292,7 @@ export {
261
292
  orgKey,
262
293
  orgRateLimit,
263
294
  } from './rate-limit';
264
- export type {
265
- PgExecutor,
266
- PostgresAuthLimiter,
267
- PostgresAuthLimiterOptions,
268
- } from './rate-limit-postgres';
295
+ export type { PostgresAuthLimiter, PostgresAuthLimiterOptions } from './rate-limit-postgres';
269
296
  export {
270
297
  postgresAuthLimiter,
271
298
  SQL_AUTH_LIMIT_TABLES,
@@ -295,7 +322,6 @@ export {
295
322
  DEFAULT_SESSION_POLICY,
296
323
  idleSlideMs,
297
324
  listDevices,
298
- readCookie,
299
325
  readSessionCookie,
300
326
  revokeOtherSessions,
301
327
  revokeSession,
package/src/jwks.ts CHANGED
@@ -207,6 +207,17 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
207
207
  .catch(() => null);
208
208
  if (key !== null) next.set(`${kid}:${alg}`, key);
209
209
  }
210
+ // A 200 with nothing importable in it is a failed fetch, not a rotation to zero keys. Adopted,
211
+ // it replaced a working cache with an empty one stamped fresh for a whole TTL.
212
+ if (next.size === 0) {
213
+ throw oauthExchangeFailed({
214
+ provider: options.provider,
215
+ stage: 'jwks',
216
+ detail: 'the key set held no key this runtime can import, so the cached keys were kept',
217
+ status: response.status,
218
+ fix: readTheKeySet(''),
219
+ });
220
+ }
210
221
  if (fence.generation() === issued) {
211
222
  keys = next;
212
223
  fetchedAtMs = clock.now().getTime();
@@ -0,0 +1,70 @@
1
+ // Single responsibility: one credential attempt's reservations across the three buckets — account,
2
+ // source address, tenant — taken BEFORE the expensive check and settled after it. `login` and
3
+ // `completeMfa` both run through this, so a password guess and a code guess are metered alike.
4
+
5
+ import type { Auth } from './auth';
6
+ import { type AuthLimiter, type AuthReservation, ipKey, orgKey } from './rate-limit';
7
+
8
+ export interface LoginAttempt {
9
+ /**
10
+ * The tenant bucket, joined once the address has resolved to an org — still before the KDF. A
11
+ * refusal here gives back what this attempt already took: an attempt the tenant cap turned
12
+ * away never tested a credential, so it must not also count against the account or address.
13
+ */
14
+ reserveOrg(orgId: string | null): Promise<void>;
15
+ /**
16
+ * No verdict was reached, or the factor checked was proven and another is still owed: every
17
+ * reservation is given back and nothing is cleared.
18
+ */
19
+ release(): Promise<void>;
20
+ /**
21
+ * The credential is fully proven. Clears the ACCOUNT bucket — the typos before it were this
22
+ * person's — and only refunds the shared ones: an address and a tenant count traffic from many
23
+ * people, so one success is not evidence the failures beside it were benign.
24
+ */
25
+ succeed(): Promise<void>;
26
+ }
27
+
28
+ interface Taken {
29
+ readonly limiter: AuthLimiter;
30
+ readonly reservation: AuthReservation;
31
+ }
32
+
33
+ /**
34
+ * Reserves account → ip, in that fixed order (and `reserveOrg` after both), so two concurrent
35
+ * sign-ins never hold two of a shared limiter's rows in opposite orders. A failed attempt calls
36
+ * nothing further: the reservations ARE the recorded failure.
37
+ */
38
+ export async function openLoginAttempt(
39
+ auth: Auth,
40
+ account: string,
41
+ ip: string | null,
42
+ ): Promise<LoginAttempt> {
43
+ const taken: Taken[] = [];
44
+ const release = async (): Promise<void> => {
45
+ for (const { limiter, reservation } of taken.splice(0)) await limiter.refund(reservation);
46
+ };
47
+ const take = async (limiter: AuthLimiter, key: string): Promise<void> => {
48
+ try {
49
+ taken.push({ limiter, reservation: await limiter.reserve(key) });
50
+ } catch (refused) {
51
+ await release();
52
+ throw refused;
53
+ }
54
+ };
55
+
56
+ await take(auth.limiter, account);
57
+ if (ip !== null) await take(auth.limiter, ipKey(ip));
58
+
59
+ return {
60
+ reserveOrg: async (orgId) => {
61
+ if (orgId !== null) await take(auth.orgLimiter, orgKey(orgId));
62
+ },
63
+ release,
64
+ succeed: async () => {
65
+ const [own, ...shared] = taken.splice(0);
66
+ if (own !== undefined) await own.limiter.recordSuccess(own.reservation.key);
67
+ for (const { limiter, reservation } of shared) await limiter.refund(reservation);
68
+ },
69
+ };
70
+ }
@@ -12,6 +12,7 @@ import type {
12
12
  AuthVerification,
13
13
  CreateUserInput,
14
14
  SessionPatch,
15
+ StoredMfaSecret,
15
16
  UserPatch,
16
17
  UserQuery,
17
18
  } from './adapter';
@@ -66,6 +67,8 @@ export class MemoryAdapter implements AuthAdapter {
66
67
  * what makes two spellings one address.
67
68
  */
68
69
  async createUser(input: CreateUserInput): Promise<AuthUser> {
70
+ // `id uuid primary key`: a `Map.set` over an existing id REPLACED the first account.
71
+ if (this.#users.has(input.id)) throw authUniqueViolation('createUser', 'x_users', 'id');
69
72
  for (const existing of this.#users.values()) {
70
73
  if (existing.email === input.email) {
71
74
  throw authUniqueViolation('createUser', 'x_users', 'email');
@@ -107,6 +110,15 @@ export class MemoryAdapter implements AuthAdapter {
107
110
  async updateUser(id: string, patch: UserPatch): Promise<AuthUser | null> {
108
111
  const user = this.#users.get(id);
109
112
  if (user === undefined) return null;
113
+ // `external_id text unique`, on the UPDATE as on the INSERT: every OTHER row, and only a
114
+ // value — NULLS DISTINCT, so clearing it never collides. `UserPatch` carries no `email`.
115
+ if (patch.externalId !== undefined && patch.externalId !== null) {
116
+ for (const other of this.#users.values()) {
117
+ if (other.id !== id && other.externalId === patch.externalId) {
118
+ throw authUniqueViolation('updateUser', 'x_users', 'external_id');
119
+ }
120
+ }
121
+ }
110
122
  const next: AuthUser = {
111
123
  ...user,
112
124
  passwordHash: patch.passwordHash === undefined ? user.passwordHash : patch.passwordHash,
@@ -125,6 +137,35 @@ export class MemoryAdapter implements AuthAdapter {
125
137
  return next;
126
138
  }
127
139
 
140
+ async listUsersWithMfaSecret(): Promise<readonly StoredMfaSecret[]> {
141
+ const stored: StoredMfaSecret[] = [];
142
+ for (const user of this.#users.values()) {
143
+ if (user.mfaSecret !== null) stored.push({ userId: user.id, mfaSecret: user.mfaSecret });
144
+ }
145
+ return stored.sort((a, b) => (a.userId < b.userId ? -1 : a.userId > b.userId ? 1 : 0));
146
+ }
147
+
148
+ // No `await` between the check and the write: the two are one step, as the UPDATE is.
149
+ async replaceMfaSecret(userId: string, expected: string, next: string): Promise<boolean> {
150
+ const user = this.#users.get(userId);
151
+ if (user === undefined || user.mfaSecret === null) return false;
152
+ if (!timingSafeEqual(user.mfaSecret, expected)) return false;
153
+ this.#users.set(userId, { ...user, mfaSecret: next });
154
+ return true;
155
+ }
156
+
157
+ // No `await` between the check and the write: the two are one step, as the UPDATE is.
158
+ async consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
159
+ const user = this.#users.get(userId);
160
+ const held = (stored: string): boolean => timingSafeEqual(stored, codeHash);
161
+ if (user === undefined || !user.recoveryCodeHashes.some(held)) return false;
162
+ this.#users.set(userId, {
163
+ ...user,
164
+ recoveryCodeHashes: user.recoveryCodeHashes.filter((stored) => !held(stored)),
165
+ });
166
+ return true;
167
+ }
168
+
128
169
  async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
129
170
  for (const user of this.#users.values()) {
130
171
  if (user.externalId === externalId) return user;