@ultimat3/auth 23.0.0 → 25.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/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,8 +61,7 @@ export {
50
61
  UserSchema,
51
62
  VerificationSchema,
52
63
  } from './auth';
53
-
54
- export { BuiltinAdapter } from './builtin-adapter';
64
+ export { type PostgresAuthAdapter, postgresAuthAdapter } 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';
57
67
  export { clientScopeOf } from './client-scope';
@@ -68,20 +78,19 @@ export {
68
78
  AuthError,
69
79
  accountLocked,
70
80
  apiKeyInvalid,
81
+ authForbidden,
71
82
  authLimiterNotShared,
72
83
  authLimiterPolicyMismatch,
73
- authNotImplemented,
84
+ authUnauthenticated,
74
85
  authUniqueViolation,
75
86
  authWriteFailed,
76
- forbidden,
77
87
  kdfOverloaded,
78
- mfaRequired,
79
88
  mfaRequiredUnenforceable,
80
89
  mfaSecretInvalid,
90
+ mfaSecretUnsealed,
81
91
  passwordWeak,
82
92
  sessionExpired,
83
93
  sessionUnknown,
84
- unauthenticated,
85
94
  } from './errors';
86
95
  export { currentActor, requireActor } from './guards';
87
96
  export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
@@ -99,22 +108,22 @@ export type {
99
108
  JwtHeader,
100
109
  } from './jwks';
101
110
  export {
102
- createJwksClient,
103
111
  decodeJwtHeader,
112
+ jwksClient,
104
113
  providerJwks,
105
114
  verifyJwtSignature,
106
115
  } from './jwks';
107
116
  export type { KdfGate, KdfLimits } from './kdf-gate';
108
117
  export {
118
+ boundedKdfGate,
109
119
  configureKdfGate,
110
- createKdfGate,
111
120
  kdfGate,
112
121
  } from './kdf-gate';
113
122
  export type { AuthLimiterFactory } from './limiter-install';
114
123
  // `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
115
124
  // caller building limiters out of band would be a second answer to where failures are counted.
116
125
  export { configureAuthLimiters, purgeAuthLimits, resetAuthLimiters } from './limiter-install';
117
- export { MemoryAdapter } from './memory-adapter';
126
+ export { type MemoryAuthAdapter, memoryAuthAdapter } from './memory-adapter';
118
127
  export type {
119
128
  EnrolTotpInput,
120
129
  MemoryTotpReplayGuard,
@@ -127,12 +136,12 @@ export type {
127
136
  export {
128
137
  base32Decode,
129
138
  base32Encode,
130
- createTotpReplayGuard,
131
139
  DEFAULT_MAX_TOTP_SUBJECTS,
132
140
  enrolTotp,
133
141
  generateRecoveryCodes,
134
142
  generateTotpSecret,
135
- redeemRecoveryCode,
143
+ memoryTotpReplayGuard,
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,
@@ -148,7 +174,7 @@ export type {
148
174
  OAuthProviderId,
149
175
  PkcePair,
150
176
  } from './oauth';
151
- export { assertOAuthCallback, beginOAuth, createPkce } from './oauth';
177
+ export { assertOAuthCallback, beginOAuth, pkcePair } from './oauth';
152
178
  export {
153
179
  APPLE_PROVIDER,
154
180
  BUILTIN_OAUTH_PROVIDER_IDS,
@@ -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,
@@ -191,13 +212,20 @@ export type {
191
212
  OAuthTokens,
192
213
  } from './oauth-exchange';
193
214
  export { exchangeOAuthCode, oauthCredentials } from './oauth-exchange';
215
+ export type { OAuthGrantContext } from './oauth-grant-context';
194
216
  export type {
195
217
  CompleteOAuthLoginInput,
196
218
  OAuthGrants,
197
219
  OAuthSignInInput,
198
220
  ResolveOAuthGrants,
199
221
  } from './oauth-login';
200
- export { completeOAuthLogin, signInWithOAuth } from './oauth-login';
222
+ export {
223
+ completeOAuthLogin,
224
+ emailVerifiedNotStored,
225
+ oauthAccountNotLinked,
226
+ oauthLinkingDisabled,
227
+ signInWithOAuth,
228
+ } from './oauth-login';
201
229
  export {
202
230
  OAUTH_BASE_PATH,
203
231
  OAUTH_CALLBACK_ROUTE_PATH,
@@ -209,12 +237,13 @@ export type { OAuthProfile, OAuthProfileOptions } from './oauth-profile';
209
237
  export { oauthProfile } from './oauth-profile';
210
238
  export {
211
239
  hasOAuthProvider,
240
+ oauthProviderDuplicate,
212
241
  oauthProviderIds,
213
242
  providerFor,
214
243
  registerOAuthProvider,
215
244
  } from './oauth-registry';
216
245
  export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
217
- export { OAUTH_ROUTE_STATUS, oauthLogin } from './oauth-route';
246
+ export { OAUTH_ROUTE_STATUS, oauthDenied, oauthLogin } from './oauth-route';
218
247
  export type {
219
248
  PasswordParams,
220
249
  PasswordPolicy,
@@ -240,6 +269,8 @@ export {
240
269
  actorFromApiKey,
241
270
  actorFromService,
242
271
  actorFromUser,
272
+ apiKeyScopes,
273
+ isWildcardScope,
243
274
  resolveActor,
244
275
  } from './policy-bridge';
245
276
  export type { UpdatePrivilegesResult } from './privileges';
@@ -248,24 +279,21 @@ export type {
248
279
  AuthLimiter,
249
280
  AuthLimiterScope,
250
281
  AuthRateLimitPolicy,
282
+ AuthReservation,
251
283
  MemoryAuthLimiter,
252
284
  } from './rate-limit';
253
285
  export {
254
286
  accountKey,
255
287
  assertAuthLimiterPolicy,
256
- createAuthLimiter,
257
288
  DEFAULT_AUTH_RATE_LIMIT,
258
289
  DEFAULT_MAX_AUTH_LIMIT_KEYS,
259
290
  ipKey,
260
291
  loginFailed,
292
+ memoryAuthLimiter,
261
293
  orgKey,
262
294
  orgRateLimit,
263
295
  } from './rate-limit';
264
- export type {
265
- PgExecutor,
266
- PostgresAuthLimiter,
267
- PostgresAuthLimiterOptions,
268
- } from './rate-limit-postgres';
296
+ export type { PostgresAuthLimiter, PostgresAuthLimiterOptions } from './rate-limit-postgres';
269
297
  export {
270
298
  postgresAuthLimiter,
271
299
  SQL_AUTH_LIMIT_TABLES,
@@ -295,8 +323,8 @@ export {
295
323
  DEFAULT_SESSION_POLICY,
296
324
  idleSlideMs,
297
325
  listDevices,
298
- readCookie,
299
326
  readSessionCookie,
327
+ remainingMaxAgeSeconds,
300
328
  revokeOtherSessions,
301
329
  revokeSession,
302
330
  rotateSession,
@@ -315,7 +343,6 @@ export {
315
343
  export {
316
344
  randomToken,
317
345
  sha256Hex,
318
- timingSafeEqual,
319
346
  } from './tokens';
320
347
  export type {
321
348
  ConsumeVerificationInput,
package/src/jwks.ts CHANGED
@@ -8,11 +8,11 @@
8
8
 
9
9
  import type { Clock, Scheduler } from '@ultimat3/core';
10
10
  import {
11
- createFence,
12
- createSingleFlight,
11
+ generationFence,
13
12
  isFixShellSafe,
14
13
  renderFixShellArg,
15
14
  renderThrowable,
15
+ singleFlight,
16
16
  systemClock,
17
17
  } from '@ultimat3/core';
18
18
  import { decodeJwtSegment, isRecord } from './json';
@@ -117,7 +117,7 @@ const algorithmOf = (jwk: Record<string, unknown>): JwtAlgorithm | null => {
117
117
  * become an unbounded outbound request per attempt, so the refresh is rate-limited by the same
118
118
  * TTL as the ordinary one.
119
119
  */
120
- export function createJwksClient(options: JwksClientOptions): JwksKeySource {
120
+ export function jwksClient(options: JwksClientOptions): JwksKeySource {
121
121
  const clock = options.clock ?? systemClock;
122
122
  const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
123
123
  assertFiniteAuthCount(
@@ -144,7 +144,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
144
144
  // already gave up on could drop a pre-rotation key set on top of the live one, and every login
145
145
  // against the new `kid` would start missing again. Read, never `guard`: a superseded refresh is
146
146
  // still the honest answer for the callers holding it, and only the shared cache is fenced.
147
- const fence = createFence('the published jwks key set');
147
+ const fence = generationFence('the published jwks key set');
148
148
  // All three refusals below put this URI in a COMMAND position, and it came out of the ISSUER's
149
149
  // own discovery document — `jwks_uri` is remote text, and `new URL()` keeps `$`, `(`, `)` and a
150
150
  // backtick in a path. `renderFixLiteral` is the wrong tool here, because its double quotes leave
@@ -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();
@@ -224,7 +235,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
224
235
  // running, its own callers keep their promise, and the fence above stops its late answer from
225
236
  // landing in the cache. So the worst case is one duplicate JWKS fetch, never a failed
226
237
  // verification — which is what makes a deadline here a fix rather than a risk.
227
- const flight = createSingleFlight({
238
+ const flight = singleFlight({
228
239
  deadlineMs: timeoutMs * JWKS_DEADLINE_FACTOR,
229
240
  schedule: options.schedule,
230
241
  });
@@ -284,7 +295,7 @@ const clients = new Map<string, JwksKeySource>();
284
295
  *
285
296
  * Not cached by option identity, deliberately: `fetch` and `clock` are functions and objects, so
286
297
  * any canonical key over them either collides (two different proxies, one entry) or never hits.
287
- * A bespoke client is a bespoke client; `createJwksClient` is what it is, and its own cache still
298
+ * A bespoke client is a bespoke client; `jwksClient` is what it is, and its own cache still
288
299
  * works for as long as the caller holds it.
289
300
  */
290
301
  export function providerJwks(
@@ -301,7 +312,7 @@ export function providerJwks(
301
312
  const bespoke = options !== undefined && Object.keys(options).length > 0;
302
313
  const existing = bespoke ? undefined : clients.get(provider.id);
303
314
  if (existing !== undefined) return existing;
304
- const client = createJwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
315
+ const client = jwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
305
316
  if (!bespoke) clients.set(provider.id, client);
306
317
  return client;
307
318
  }
package/src/kdf-gate.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // what argon2id costs is MEMORY — 19 MiB per hash at the OWASP floor. The only remaining backstop
7
7
  // was `http.maxInflight` (1000), i.e. roughly 19 GB of arenas queued on one box.
8
8
 
9
- import { createFlightGate } from '@ultimat3/core';
9
+ import { flightGate } from '@ultimat3/core';
10
10
  import { kdfOverloaded } from './errors';
11
11
  import { assertFiniteAuthCount } from './policy-numbers';
12
12
 
@@ -43,7 +43,7 @@ export interface KdfGate {
43
43
  * The declared return type stays `KdfGate` rather than core's `FlightGate`: `active` and `queued`
44
44
  * are observations no caller here has ever had, and widening a public signature is not a refactor.
45
45
  */
46
- export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
46
+ export function boundedKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
47
47
  // Screened at the CONSTRUCTOR, because this pair WEDGES rather than fails: core asks
48
48
  // `active < maxConcurrent` and then `waiters.length >= maxQueued`, and both are false for `NaN`,
49
49
  // so every hash on the box parks in a queue with no bound and nothing to release it — login
@@ -66,12 +66,12 @@ export function createKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate {
66
66
  '`waiters.length >= NaN` is false, so the queue this gate exists to bound has no bound at all',
67
67
  0,
68
68
  );
69
- return createFlightGate(limits, {
69
+ return flightGate(limits, {
70
70
  overflow: (state) => kdfOverloaded(state.active, state.queued),
71
71
  });
72
72
  }
73
73
 
74
- let gate = createKdfGate();
74
+ let gate = boundedKdfGate();
75
75
 
76
76
  /** The process-wide gate every `hashPassword`/`verifyPassword` passes through. */
77
77
  export const kdfGate = (): KdfGate => gate;
@@ -83,10 +83,10 @@ export const kdfGate = (): KdfGate => gate;
83
83
  * runs on a 512 MiB PaaS dyno and a 64 GiB node.
84
84
  */
85
85
  export function configureKdfGate(limits: KdfLimits): void {
86
- gate = createKdfGate(limits);
86
+ gate = boundedKdfGate(limits);
87
87
  }
88
88
 
89
89
  /** Back to the shipped defaults. Tests that call `configureKdfGate` must call this in cleanup. */
90
90
  export function resetKdfGate(): void {
91
- gate = createKdfGate();
91
+ gate = boundedKdfGate();
92
92
  }
@@ -52,7 +52,7 @@ export function configureAuthLimiters(next: AuthLimiterFactory): void {
52
52
  built = new Map();
53
53
  }
54
54
 
55
- /** Back to `createAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
55
+ /** Back to `memoryAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
56
56
  export function resetAuthLimiters(): void {
57
57
  factory = undefined;
58
58
  built = new Map();
@@ -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
+ }
@@ -2,7 +2,7 @@
2
2
  // database exists and the one every test in this package runs against — the same interface
3
3
  // Postgres and Better Auth implement, so a flow that works here works there or the seam is wrong.
4
4
 
5
- import { type Clock, systemClock } from '@ultimat3/core';
5
+ import { type Clock, systemClock, timingSafeEqual } from '@ultimat3/core';
6
6
  import type {
7
7
  AuthAccount,
8
8
  AuthAdapter,
@@ -12,15 +12,33 @@ import type {
12
12
  AuthVerification,
13
13
  CreateUserInput,
14
14
  SessionPatch,
15
+ StoredMfaSecret,
15
16
  UserPatch,
16
17
  UserQuery,
17
18
  } from './adapter';
18
19
  import { authUniqueViolation } from './errors';
19
- import { timingSafeEqual } from './tokens';
20
20
 
21
21
  const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
22
22
 
23
- export class MemoryAdapter implements AuthAdapter {
23
+ /**
24
+ * `PostgresAuthAdapter`'s `order by email collate "C"`: byte order over UTF-8, which is CODE-POINT order
25
+ * — never `localeCompare` (the machine's locale) and never `<` on strings, whose UTF-16 code units
26
+ * put an astral character (a lead surrogate, 0xD8xx) ahead of U+E000–U+FFFF.
27
+ */
28
+ const byCodePoint = (a: string, b: string): number => {
29
+ const left = a[Symbol.iterator]();
30
+ const right = b[Symbol.iterator]();
31
+ for (;;) {
32
+ const x = left.next();
33
+ const y = right.next();
34
+ if (x.done === true || y.done === true)
35
+ return (x.done === true ? 0 : 1) - (y.done === true ? 0 : 1);
36
+ const delta = (x.value.codePointAt(0) ?? 0) - (y.value.codePointAt(0) ?? 0);
37
+ if (delta !== 0) return delta;
38
+ }
39
+ };
40
+
41
+ export class MemoryAuthAdapter implements AuthAdapter {
24
42
  readonly name = 'memory';
25
43
  readonly #clock: Clock;
26
44
  readonly #users = new Map<string, AuthUser>();
@@ -32,14 +50,14 @@ export class MemoryAdapter implements AuthAdapter {
32
50
  /**
33
51
  * The clock every instant this adapter stamps comes from — one argument, because a stamp is a
34
52
  * fact about WHEN a call happened and a test that cannot move it can only assert a range.
35
- * Defaults to `systemClock`, so `new MemoryAdapter()` is what it always was.
53
+ * Defaults to `systemClock`, so `memoryAuthAdapter()` is what it always was.
36
54
  */
37
55
  constructor(clock: Clock = systemClock) {
38
56
  this.#clock = clock;
39
57
  }
40
58
 
41
59
  /**
42
- * Exact match, because `BuiltinAdapter` issues `where email = $1` against a plain `text ...
60
+ * Exact match, because `PostgresAuthAdapter` issues `where email = $1` against a plain `text ...
43
61
  * unique` column and nothing folds case there. Normalising here instead made this the ONE
44
62
  * adapter that found an account Postgres would not, which is a linked account under `x dev` and
45
63
  * a duplicate one in production. `normaliseEmail` is the caller's, above the seam.
@@ -56,7 +74,7 @@ export class MemoryAdapter implements AuthAdapter {
56
74
  }
57
75
 
58
76
  /**
59
- * The two UNIQUE constraints `x_users` declares, enforced here because `BuiltinAdapter` LEANS on
77
+ * The two UNIQUE constraints `x_users` declares, enforced here because `PostgresAuthAdapter` LEANS on
60
78
  * them: `email text not null unique` and `external_id text unique` (`tables.ts`). Without them
61
79
  * this adapter — the one `x new` scaffolds and every test runs against — accepted two rows at one
62
80
  * address, and the second was unreachable forever, since `findUserByEmail` returns the first.
@@ -66,6 +84,8 @@ export class MemoryAdapter implements AuthAdapter {
66
84
  * what makes two spellings one address.
67
85
  */
68
86
  async createUser(input: CreateUserInput): Promise<AuthUser> {
87
+ // `id uuid primary key`: a `Map.set` over an existing id REPLACED the first account.
88
+ if (this.#users.has(input.id)) throw authUniqueViolation('createUser', 'x_users', 'id');
69
89
  for (const existing of this.#users.values()) {
70
90
  if (existing.email === input.email) {
71
91
  throw authUniqueViolation('createUser', 'x_users', 'email');
@@ -107,6 +127,15 @@ export class MemoryAdapter implements AuthAdapter {
107
127
  async updateUser(id: string, patch: UserPatch): Promise<AuthUser | null> {
108
128
  const user = this.#users.get(id);
109
129
  if (user === undefined) return null;
130
+ // `external_id text unique`, on the UPDATE as on the INSERT: every OTHER row, and only a
131
+ // value — NULLS DISTINCT, so clearing it never collides. `UserPatch` carries no `email`.
132
+ if (patch.externalId !== undefined && patch.externalId !== null) {
133
+ for (const other of this.#users.values()) {
134
+ if (other.id !== id && other.externalId === patch.externalId) {
135
+ throw authUniqueViolation('updateUser', 'x_users', 'external_id');
136
+ }
137
+ }
138
+ }
110
139
  const next: AuthUser = {
111
140
  ...user,
112
141
  passwordHash: patch.passwordHash === undefined ? user.passwordHash : patch.passwordHash,
@@ -125,6 +154,35 @@ export class MemoryAdapter implements AuthAdapter {
125
154
  return next;
126
155
  }
127
156
 
157
+ async listUsersWithMfaSecret(): Promise<readonly StoredMfaSecret[]> {
158
+ const stored: StoredMfaSecret[] = [];
159
+ for (const user of this.#users.values()) {
160
+ if (user.mfaSecret !== null) stored.push({ userId: user.id, mfaSecret: user.mfaSecret });
161
+ }
162
+ return stored.sort((a, b) => (a.userId < b.userId ? -1 : a.userId > b.userId ? 1 : 0));
163
+ }
164
+
165
+ // No `await` between the check and the write: the two are one step, as the UPDATE is.
166
+ async replaceMfaSecret(userId: string, expected: string, next: string): Promise<boolean> {
167
+ const user = this.#users.get(userId);
168
+ if (user === undefined || user.mfaSecret === null) return false;
169
+ if (!timingSafeEqual(user.mfaSecret, expected)) return false;
170
+ this.#users.set(userId, { ...user, mfaSecret: next });
171
+ return true;
172
+ }
173
+
174
+ // No `await` between the check and the write: the two are one step, as the UPDATE is.
175
+ async consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
176
+ const user = this.#users.get(userId);
177
+ const held = (stored: string): boolean => timingSafeEqual(stored, codeHash);
178
+ if (user === undefined || !user.recoveryCodeHashes.some(held)) return false;
179
+ this.#users.set(userId, {
180
+ ...user,
181
+ recoveryCodeHashes: user.recoveryCodeHashes.filter((stored) => !held(stored)),
182
+ });
183
+ return true;
184
+ }
185
+
128
186
  async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
129
187
  for (const user of this.#users.values()) {
130
188
  if (user.externalId === externalId) return user;
@@ -137,7 +195,7 @@ export class MemoryAdapter implements AuthAdapter {
137
195
  .filter((user) => user.orgId === orgId)
138
196
  .filter((user) => query?.includeDisabled === true || user.disabledAt === null)
139
197
  .filter((user) => query?.role === undefined || user.roles.includes(query.role))
140
- .sort((a, b) => a.email.localeCompare(b.email));
198
+ .sort((a, b) => byCodePoint(a.email, b.email));
141
199
  }
142
200
 
143
201
  async getSession(id: string): Promise<AuthSession | null> {
@@ -289,3 +347,11 @@ export class MemoryAdapter implements AuthAdapter {
289
347
  return true;
290
348
  }
291
349
  }
350
+
351
+ /**
352
+ * The one way to build the in-memory adapter — the twin of `postgresAuthAdapter()`. The class is a
353
+ * type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
354
+ */
355
+ export function memoryAuthAdapter(clock: Clock = systemClock): MemoryAuthAdapter {
356
+ return new MemoryAuthAdapter(clock);
357
+ }