@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/adapter.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: the persistence seam. `AuthAdapter` is the one interface auth talks
2
2
  // to, split into per-concern stores so a test (or a caller) can satisfy just the slice it uses.
3
3
  // Better Auth binds here — it is an adapter implementation, not a dependency. The blessed
4
- // default is `BuiltinAdapter` in `builtin-adapter.ts`; the DDL it expects is in `tables.ts`.
4
+ // default is `PostgresAuthAdapter` in `builtin-adapter.ts`; the DDL it expects is in `tables.ts`.
5
5
 
6
6
  export interface AuthUser {
7
7
  readonly id: string;
@@ -22,7 +22,10 @@ export interface AuthUser {
22
22
  * the operator's identity away. Empty for almost every account.
23
23
  */
24
24
  readonly scopes: readonly string[];
25
- /** Base32 TOTP secret, or `null` when MFA is not enrolled. */
25
+ /**
26
+ * The TOTP secret AS STORED — sealed (`mfa-secret.ts`), never base32 — or `null` when MFA is
27
+ * not enrolled. Only `openTotpSecret` turns it into something a code can be checked against.
28
+ */
26
29
  readonly mfaSecret: string | null;
27
30
  readonly recoveryCodeHashes: readonly string[];
28
31
  /**
@@ -71,14 +74,40 @@ export interface UserStore {
71
74
  createUser(input: CreateUserInput): Promise<AuthUser>;
72
75
  updateUser(id: string, patch: UserPatch): Promise<AuthUser | null>;
73
76
  /**
74
- * Optional so an adapter written against 1.2's seam still compiles; both shipped adapters
75
- * implement it, and `directory.ts` answers `X_NOT_IMPLEMENTED` with the method name when one
76
- * does not. The alternative was a required member, which is a breaking change to every
77
- * third-party `AuthAdapter` for a capability most of them will never be asked for.
77
+ * Required, like every member of the seam: an adapter that lacks one is a compile error at the
78
+ * `defineAuth({ adapter })` it is handed to, never an `X_NOT_IMPLEMENTED` at the first SCIM
79
+ * lookup in production. Exact match on the stored string, `null` when nobody carries it.
80
+ */
81
+ findUserByExternalId(externalId: string): Promise<AuthUser | null>;
82
+ /**
83
+ * Enumeration — a quarterly access review needs it. Ordered by email ascending; disabled members
84
+ * are left out unless `query.includeDisabled` is `true`.
85
+ */
86
+ listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]>;
87
+ /**
88
+ * Every user holding a second-factor secret, with the value exactly as stored, ordered by id.
89
+ * The one enumeration `sealMfaSecrets` needs: it cannot seal what it cannot find, and whether a
90
+ * stored value is sealed is core's `isSealed` to say — never a `like` in an adapter.
91
+ */
92
+ listUsersWithMfaSecret(): Promise<readonly StoredMfaSecret[]>;
93
+ /**
94
+ * Compare-and-set on one user's stored secret: write `next` only while the row still holds
95
+ * `expected`, in ONE atomic step, and answer whether it did. What `sealMfaSecrets` writes
96
+ * through — a user who re-enrolled since the value was read must keep the NEW secret, and a
97
+ * plain write would put the old one back, sealed.
78
98
  */
79
- findUserByExternalId?(externalId: string): Promise<AuthUser | null>;
80
- /** Enumeration, which nothing in the seam could do — a quarterly access review needs it. */
81
- listUsersByOrg?(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]>;
99
+ replaceMfaSecret(userId: string, expected: string, next: string): Promise<boolean>;
100
+ /**
101
+ * Remove one recovery-code hash from one user, and answer whether it was there — in ONE atomic
102
+ * step. Single-use is a storage guarantee, exactly as `takeVerification`'s is: two concurrent
103
+ * redemptions of one code must not both find it, which a read followed by a write lets happen.
104
+ */
105
+ consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean>;
106
+ }
107
+
108
+ export interface StoredMfaSecret {
109
+ readonly userId: string;
110
+ readonly mfaSecret: string;
82
111
  }
83
112
 
84
113
  export interface AuthSession {
@@ -108,6 +137,10 @@ export interface SessionStore {
108
137
  getSession(id: string): Promise<AuthSession | null>;
109
138
  createSession(session: AuthSession): Promise<AuthSession>;
110
139
  updateSession(id: string, patch: SessionPatch): Promise<AuthSession | null>;
140
+ /**
141
+ * Remove one session and answer whether THIS call removed it, in one atomic step: `rotateSession`
142
+ * treats `true` as its claim on the id, so two concurrent deletes must not both answer `true`.
143
+ */
111
144
  deleteSession(id: string): Promise<boolean>;
112
145
  /** Returns how many were killed — the "sign out everywhere else" number shown to the user. */
113
146
  deleteOtherSessions(userId: string, keepSessionId: string): Promise<number>;
@@ -115,17 +148,15 @@ export interface SessionStore {
115
148
  /**
116
149
  * Every session this user holds, including the caller's own. `deleteOtherSessions` cannot
117
150
  * express it — there is no session id to keep — and a password change or a disable has to.
118
- *
119
- * Optional for the reason `findUserByExternalId` is: adding a required member to a shipped
120
- * seam breaks every third-party adapter. `revocation.ts` refuses with `X_NOT_IMPLEMENTED` and
121
- * names the method when an adapter has not got it.
151
+ * Returns how many were killed.
122
152
  */
123
- deleteSessionsForUser?(userId: string): Promise<number>;
153
+ deleteSessionsForUser(userId: string): Promise<number>;
124
154
  /**
125
155
  * Everything issued before an instant. The credential-compromise sweep: rotate the secret,
126
- * then kill everything minted under the old one, without enumerating users.
156
+ * then kill everything minted under the old one, without enumerating users. Strictly before:
157
+ * a session created AT the instant survives.
127
158
  */
128
- deleteSessionsCreatedBefore?(before: Date): Promise<number>;
159
+ deleteSessionsCreatedBefore(before: Date): Promise<number>;
129
160
  }
130
161
 
131
162
  export interface AuthAccount {
@@ -200,7 +231,7 @@ export interface ApiKeyStore {
200
231
  }
201
232
 
202
233
  /**
203
- * The full seam. One blessed implementation ships (`BuiltinAdapter`); Better Auth, or any
234
+ * The full seam. One blessed implementation ships (`PostgresAuthAdapter`); Better Auth, or any
204
235
  * other identity backend, binds by implementing this and nothing else changes upstream.
205
236
  */
206
237
  export interface AuthAdapter
@@ -218,5 +249,5 @@ export interface AuthAdapter
218
249
  * exactly the sessions it was run for. So it joins through `x_users`, which is why it sits on
219
250
  * the full adapter rather than on `SessionStore`.
220
251
  */
221
- deleteSessionsForOrg?(orgId: string): Promise<number>;
252
+ deleteSessionsForOrg(orgId: string): Promise<number>;
222
253
  }
@@ -4,8 +4,12 @@
4
4
  // leaves everything else a throw, because "the database is down" is not "your key is wrong".
5
5
 
6
6
  import { type Clock, isUltimateError, systemClock } from '@ultimat3/core';
7
- import type { ApiKeyStore } from './adapter';
8
- import { apiKeyActor, verifyApiKey } from './api-keys';
7
+ import {
8
+ type ApiKeyActorOptions,
9
+ type ApiKeyVerifyStore,
10
+ apiKeyActor,
11
+ verifyApiKey,
12
+ } from './api-keys';
9
13
  import type { PolicyActor } from './policy-bridge';
10
14
 
11
15
  /**
@@ -15,11 +19,11 @@ import type { PolicyActor } from './policy-bridge';
15
19
  export interface ApiKeyCaller {
16
20
  /** The agent actor: the key's org, and its scopes as the permissions a policy reads. */
17
21
  readonly actor: PolicyActor;
18
- /** The key's scopes — what the mount's scope map is cut by. */
22
+ /** The scopes the ACTOR carries — the key's, cut by its owner's grants. The mount's scope map is cut by these. */
19
23
  readonly scopes: ReadonlySet<string>;
20
24
  }
21
25
 
22
- export interface ApiKeyResolverOptions {
26
+ export interface ApiKeyResolverOptions extends ApiKeyActorOptions {
23
27
  readonly clock?: Clock | undefined;
24
28
  }
25
29
 
@@ -28,18 +32,19 @@ export interface ApiKeyResolverOptions {
28
32
  * `defineAppMcp()` — one resolver for both.
29
33
  *
30
34
  * `store` is a THUNK, read when a token is presented and never captured: a mount is declared when
31
- * its module is evaluated, and `new BuiltinAdapter()` takes the process's database client, which
32
- * boot installs later. `() => new MemoryAdapter()` would be a new, empty store per request — hold
35
+ * its module is evaluated, and `postgresAuthAdapter()` takes the process's database client, which
36
+ * boot installs later. `() => memoryAuthAdapter()` would be a new, empty store per request — hold
33
37
  * the instance and return it.
34
38
  */
35
39
  export function apiKeyResolver(
36
- store: () => ApiKeyStore,
40
+ store: () => ApiKeyVerifyStore,
37
41
  options: ApiKeyResolverOptions = {},
38
42
  ): (token: string) => Promise<ApiKeyCaller | null> {
39
43
  return async (token) => {
40
44
  try {
41
- const record = await verifyApiKey(store(), token, options.clock ?? systemClock);
42
- return { actor: apiKeyActor(record), scopes: new Set(record.scopes) };
45
+ const verified = await verifyApiKey(store(), token, options.clock ?? systemClock);
46
+ const actor = apiKeyActor(verified, options);
47
+ return { actor, scopes: new Set(actor.scopes) };
43
48
  } catch (thrown) {
44
49
  // Every way a key can be wrong is this one code, by `verifyApiKey`'s own design: a caller
45
50
  // that could tell "revoked" from "unknown" could enumerate ids.
package/src/api-keys.ts CHANGED
@@ -4,12 +4,12 @@
4
4
  // evaluation a human does. The plaintext is shown once; only its SHA-256 is ever stored, and
5
5
  // lookup happens by the non-secret id so the secret never appears in a query, an index or a log.
6
6
 
7
- import { type Clock, randomHex, systemClock } from '@ultimat3/core';
8
- import type { ApiKeyStore, AuthApiKeyRecord } from './adapter';
9
- import { apiKeyEnvInvalid, apiKeyInvalid } from './errors';
7
+ import { type Clock, randomHex, systemClock, timingSafeEqual } from '@ultimat3/core';
8
+ import type { ApiKeyStore, AuthApiKeyRecord, AuthUser, UserStore } from './adapter';
9
+ import { apiKeyEnvInvalid, apiKeyInvalid, apiKeyScopeWildcard } from './errors';
10
10
  import type { PolicyActor } from './policy-bridge';
11
- import { actorFromApiKey } from './policy-bridge';
12
- import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
11
+ import { actorFromApiKey, isWildcardScope } from './policy-bridge';
12
+ import { randomToken, sha256Hex } from './tokens';
13
13
 
14
14
  export const API_KEY_NAMESPACE = 'ult';
15
15
 
@@ -63,6 +63,8 @@ const API_KEY_ENV = /^[a-z0-9-]+$/;
63
63
 
64
64
  export function issueApiKey(input: IssueApiKeyInput): IssuedApiKey {
65
65
  if (!API_KEY_ENV.test(input.env)) throw apiKeyEnvInvalid(input.env);
66
+ const wildcard = input.scopes.find(isWildcardScope);
67
+ if (wildcard !== undefined) throw apiKeyScopeWildcard(wildcard);
66
68
  const clock = input.clock ?? systemClock;
67
69
  // Hex, not base64url: the id sits between two `_` delimiters and must not contain one.
68
70
  const id = randomHex(8);
@@ -85,16 +87,31 @@ export function issueApiKey(input: IssueApiKeyInput): IssuedApiKey {
85
87
  };
86
88
  }
87
89
 
90
+ /** What verification reads: the key, and the user row of whoever owns it. */
91
+ export type ApiKeyVerifyStore = ApiKeyStore & Pick<UserStore, 'findUserById'>;
92
+
93
+ export interface VerifiedApiKey {
94
+ readonly record: AuthApiKeyRecord;
95
+ /** The user the key was issued to, as the row reads NOW. `null` for a key no user owns. */
96
+ readonly owner: AuthUser | null;
97
+ }
98
+
88
99
  /**
89
- * Every rejection — malformed, unknown, revoked, expired, wrong secret — throws the same
90
- * `X_API_KEY_INVALID`. A caller that can tell "revoked" from "unknown" can enumerate ids.
100
+ * Every rejection — malformed, unknown, revoked, expired, wrong secret, an owner who is gone or
101
+ * disabled — throws the same `X_API_KEY_INVALID`. A caller that can tell "revoked" from "unknown"
102
+ * can enumerate ids.
103
+ *
104
+ * The owner is re-read on every verification, for the reason `authenticate` re-reads the user on
105
+ * every request: a key is the owner's credential, so disabling the owner has to stop it on its
106
+ * next use — not whenever somebody remembers to revoke it. The read happens only after the secret
107
+ * matched, so an unauthenticated guess costs no second lookup.
91
108
  */
92
109
  export async function verifyApiKey(
93
- store: ApiKeyStore,
110
+ store: ApiKeyVerifyStore,
94
111
  plaintext: string,
95
112
  clock: Clock = systemClock,
96
- ): Promise<AuthApiKeyRecord> {
97
- const parsed = parseApiKey(plaintext);
113
+ ): Promise<VerifiedApiKey> {
114
+ const parsed = typeof plaintext === 'string' ? parseApiKey(plaintext) : null;
98
115
  if (parsed === null) throw apiKeyInvalid();
99
116
  const record = await store.findApiKeyById(parsed.id);
100
117
  if (record === null) throw apiKeyInvalid();
@@ -104,8 +121,12 @@ export async function verifyApiKey(
104
121
  throw apiKeyInvalid();
105
122
  }
106
123
  if (!timingSafeEqual(sha256Hex(parsed.secret), record.keyHash)) throw apiKeyInvalid();
124
+ const owner = record.userId === null ? null : await store.findUserById(record.userId);
125
+ if (record.userId !== null && (owner === null || owner.disabledAt !== null)) {
126
+ throw apiKeyInvalid();
127
+ }
107
128
  await store.touchApiKey(record.id, now);
108
- return record;
129
+ return { record, owner };
109
130
  }
110
131
 
111
132
  export async function revokeApiKey(
@@ -116,9 +137,35 @@ export async function revokeApiKey(
116
137
  return await store.revokeApiKey(id, clock.now());
117
138
  }
118
139
 
119
- /** The agent actor for a verified key. Scopes in, scopes out — nothing is added. */
120
- export function apiKeyActor(record: AuthApiKeyRecord): PolicyActor {
121
- return actorFromApiKey(record);
140
+ /**
141
+ * What an owner may do, as far as this package can read it: the row's direct `permissions` and
142
+ * `scopes`. ROLES are not here — expanding a role to permissions is `@ultimat3/policy`'s, which
143
+ * this tier cannot import — so an app whose users hold roles passes `grantsOf` and expands them.
144
+ */
145
+ export const directGrants = (owner: AuthUser): readonly string[] => [
146
+ ...owner.permissions,
147
+ ...owner.scopes,
148
+ ];
149
+
150
+ export interface ApiKeyActorOptions {
151
+ /**
152
+ * The owner's effective grants, which a key's scopes are cut down to. Defaults to
153
+ * `directGrants`. A role-based app passes its own expansion, e.g.
154
+ * `(owner) => [...directGrants(owner), ...owner.roles.flatMap((role) => ROLE_GRANTS[role] ?? [])]`.
155
+ */
156
+ readonly grantsOf?: ((owner: AuthUser) => readonly string[]) | undefined;
157
+ }
158
+
159
+ /**
160
+ * The agent actor for a verified key. Nothing is added: the key's scopes, minus any wildcard,
161
+ * minus — for a key a user owns — whatever that user could not do themselves.
162
+ */
163
+ export function apiKeyActor(
164
+ verified: VerifiedApiKey,
165
+ options: ApiKeyActorOptions = {},
166
+ ): PolicyActor {
167
+ const { record, owner } = verified;
168
+ return actorFromApiKey(record, owner === null ? null : (options.grantsOf ?? directGrants)(owner));
122
169
  }
123
170
 
124
171
  /** Safe to render in a dashboard or return from an MCP tool: no hash, no secret. */
package/src/auth.ts CHANGED
@@ -3,12 +3,15 @@
3
3
  // (http, actions, jobs, MCP) authorizes on that and never on a session row, so there is one
4
4
  // identity shape in the framework and `@ultimat3/policy` is the only thing that reads it.
5
5
 
6
- import { type Clock, systemClock, uuid } from '@ultimat3/core';
6
+ import { type Clock, systemClock, timingSafeEqual, uuidV7 } 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, mfaRequiredUnenforceable, sessionUnknown } from './errors';
10
+ import { mfaRequiredUnenforceable, sessionUnknown } from './errors';
11
11
  import { installedAuthLimiter } from './limiter-install';
12
+ import { openLoginAttempt } from './login-attempt';
13
+ import { memoryTotpReplayGuard, type TotpReplayGuard } from './mfa';
14
+ import { mfaChallengeRequired } from './mfa-challenge';
12
15
  import type { OAuthProviderId } from './oauth';
13
16
  import {
14
17
  checkPasswordStrength,
@@ -24,11 +27,9 @@ import {
24
27
  type AuthRateLimitPolicy,
25
28
  accountKey,
26
29
  assertAuthLimiterPolicy,
27
- createAuthLimiter,
28
30
  DEFAULT_AUTH_RATE_LIMIT,
29
- ipKey,
30
31
  loginFailed,
31
- orgKey,
32
+ memoryAuthLimiter,
32
33
  orgRateLimit,
33
34
  } from './rate-limit';
34
35
  import {
@@ -40,7 +41,7 @@ import {
40
41
  sessionCookie,
41
42
  verifySession,
42
43
  } from './session';
43
- import { sha256Hex, timingSafeEqual } from './tokens';
44
+ import { sha256Hex } from './tokens';
44
45
 
45
46
  // The projections safe to hand to a client or an MCP tool: no password hash, no TOTP secret,
46
47
  // no token hash. The private columns live in `adapter.ts` and never leave the server.
@@ -135,7 +136,7 @@ export interface AuthConfigInput {
135
136
  readonly password?: Partial<PasswordPolicy> | undefined;
136
137
  readonly rateLimit?: Partial<AuthRateLimitPolicy> | undefined;
137
138
  /**
138
- * Where failed attempts are counted. Omitted means `createAuthLimiter`, which is one process'
139
+ * Where failed attempts are counted. Omitted means `memoryAuthLimiter`, which is one process'
139
140
  * worth of state — correct for dev and tests, and `maxAttempts × N` for N replicas. An app that
140
141
  * runs more than one declares `rateLimit.scope: 'shared'` and passes a limiter that says the
141
142
  * same, or `defineAuth` refuses here rather than at 3am on the first spray.
@@ -147,6 +148,12 @@ export interface AuthConfigInput {
147
148
  */
148
149
  readonly orgLimiter?: AuthLimiter | undefined;
149
150
  readonly mfa?: Partial<AuthMfaPolicy> | undefined;
151
+ /**
152
+ * Which TOTP steps have been spent, read and written by `completeMfa`. Omitted means
153
+ * `memoryTotpReplayGuard()` — one process' worth of memory, so a code is single-use per replica;
154
+ * a fleet that needs it single-use everywhere passes a shared guard with the same two methods.
155
+ */
156
+ readonly totpReplay?: TotpReplayGuard | undefined;
150
157
  /**
151
158
  * The OAuth providers this app serves login routes for. Defaults to `[]` — an empty list is
152
159
  * "no OAuth", and every `/auth/oauth/<id>` answers `X_OAUTH_PROVIDER_UNKNOWN`. Never the live
@@ -168,6 +175,7 @@ export interface Auth {
168
175
  readonly orgRateLimit: AuthRateLimitPolicy;
169
176
  readonly orgLimiter: AuthLimiter;
170
177
  readonly mfa: AuthMfaPolicy;
178
+ readonly totpReplay: TotpReplayGuard;
171
179
  readonly providers: readonly OAuthProviderId[];
172
180
  readonly link: OAuthLinkPolicy;
173
181
  }
@@ -189,13 +197,13 @@ export function defineAuth(config: AuthConfigInput): Auth {
189
197
  // scaffolded app had to remember to build a shared limiter itself, which is the opposite of
190
198
  // what this framework promises — and `x new` scaffolds two replicas.
191
199
  const limiter =
192
- config.limiter ?? installedAuthLimiter(rateLimit) ?? createAuthLimiter(clock, rateLimit);
200
+ config.limiter ?? installedAuthLimiter(rateLimit) ?? memoryAuthLimiter(clock, rateLimit);
193
201
  assertAuthLimiterPolicy(rateLimit, limiter);
194
202
  // The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
195
203
  // that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
196
204
  // for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
197
205
  // throughput ceiling and discloses nothing. Only the LOCAL fallback is exempt, and exempting it
198
- // is what buys that: `createAuthLimiter` always reports `'process'`, which is the one arm the
206
+ // is what buys that: `memoryAuthLimiter` always reports `'process'`, which is the one arm the
199
207
  // scope check would refuse. A limiter somebody else supplied — injected by the app or built by
200
208
  // the host's factory — is compared exactly as the general bucket's is, because a factory that
201
209
  // ignores the policy it was handed otherwise enforces numbers the app never declared while
@@ -203,7 +211,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
203
211
  // is refused for one bucket and trusted for the other.
204
212
  const orgLimits = orgRateLimit(rateLimit);
205
213
  const suppliedOrgLimiter = config.orgLimiter ?? installedAuthLimiter(orgLimits);
206
- const orgLimiter = suppliedOrgLimiter ?? createAuthLimiter(clock, orgLimits);
214
+ const orgLimiter = suppliedOrgLimiter ?? memoryAuthLimiter(clock, orgLimits);
207
215
  if (suppliedOrgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, suppliedOrgLimiter);
208
216
  // Read through a widened local on purpose: the field's type is the literal `false`, so this
209
217
  // branch is unreachable from TypeScript and reachable from every JS caller and every config
@@ -224,6 +232,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
224
232
  orgRateLimit: orgLimiter.policy,
225
233
  orgLimiter,
226
234
  mfa,
235
+ totpReplay: config.totpReplay ?? memoryTotpReplayGuard(),
227
236
  // BREAKING (majors only): the default is `[]`, never the live registry.
228
237
  //
229
238
  // It was `oauthProviderIds()`, so `defineAuth({ providers })`'s own documented purpose — the
@@ -250,7 +259,7 @@ export interface RegisterInput {
250
259
  export async function register(auth: Auth, input: RegisterInput): Promise<AuthUser> {
251
260
  checkPasswordStrength(input.password, { policy: auth.password });
252
261
  return await auth.adapter.createUser({
253
- id: uuid(auth.clock),
262
+ id: uuidV7(auth.clock),
254
263
  email: normaliseEmail(input.email),
255
264
  passwordHash: await hashPassword(input.password, auth.password.params),
256
265
  orgId: input.orgId ?? null,
@@ -278,50 +287,46 @@ export interface LoginResult {
278
287
  * Every failure path here throws `loginFailed()` — unknown address, wrong password and
279
288
  * disabled account are indistinguishable in both message and duration. The only paths that
280
289
  * throw something else are lockout (before any work) and MFA (after the password is proven).
290
+ *
291
+ * The attempt is RESERVED against every bucket before the KDF and settled after it: a burst of
292
+ * concurrent guesses spends the allowance as it arrives, so at most `maxAttempts` of them ever
293
+ * reach the hash. A failure leaves its reservations standing — they are the recorded failure.
281
294
  */
282
295
  export async function login(auth: Auth, input: LoginInput): Promise<LoginResult> {
283
- const account = accountKey(input.email);
284
296
  const ip = input.ip ?? null;
285
- await auth.limiter.assertAllowed(account);
286
- if (ip !== null) await auth.limiter.assertAllowed(ipKey(ip));
297
+ const attempt = await openLoginAttempt(auth, accountKey(input.email), ip);
287
298
 
288
- const user = await auth.adapter.findUserByEmail(normaliseEmail(input.email));
289
- // The tenant bucket can only be consulted once the address resolves to an org, which is still
290
- // before the KDF runs — the expensive half of this function — so it costs one map lookup and
291
- // caps a spray that per-IP and per-account buckets both let through.
292
- const org = user?.orgId ?? null;
293
- if (org !== null) await auth.orgLimiter.assertAllowed(orgKey(org));
294
-
295
- const usable = user !== null && user.disabledAt === null;
296
- const verification = await verifyPassword({
297
- hash: usable ? user.passwordHash : null,
298
- password: input.password,
299
- params: auth.password.params,
300
- });
301
-
302
- if (!verification.ok || user === null) {
303
- await auth.limiter.recordFailure(account);
304
- if (ip !== null) await auth.limiter.recordFailure(ipKey(ip));
305
- if (org !== null) await auth.orgLimiter.recordFailure(orgKey(org));
306
- throw loginFailed();
299
+ let user: AuthUser | null;
300
+ let verification: Awaited<ReturnType<typeof verifyPassword>>;
301
+ try {
302
+ user = await auth.adapter.findUserByEmail(normaliseEmail(input.email));
303
+ // The tenant bucket can only be joined once the address resolves to an org, which is still
304
+ // before the KDF runs — the expensive half of this function — and it caps a spray that
305
+ // per-IP and per-account buckets both let through.
306
+ await attempt.reserveOrg(user?.orgId ?? null);
307
+ verification = await verifyPassword({
308
+ hash: user !== null && user.disabledAt === null ? user.passwordHash : null,
309
+ password: input.password,
310
+ params: auth.password.params,
311
+ });
312
+ } catch (thrown) {
313
+ // No verdict — a shed KDF, a store that is down, a tenant cap — is not a failed guess.
314
+ await attempt.release();
315
+ throw thrown;
307
316
  }
308
317
 
309
- await auth.limiter.recordSuccess(account);
310
- // ONLY the account bucket is cleared. The ACCOUNT window belongs to one person, so a success
311
- // proves the typos before it were theirs and clearing it is what stops a typo costing a lockout.
312
- //
313
- // Neither the IP nor the tenant bucket is cleared, and it is the same argument for both: they
314
- // count traffic from a SHARED source, so a success is not evidence the failures beside it were
315
- // benign. `recordSuccess(ipKey(ip))` used to run here and deleted the whole address bucket —
316
- // which made it inert against the attack it exists for. A credential-stuffing run never spends
317
- // `maxAttempts` guesses on one account, so the per-account bucket never fires; the address
318
- // bucket is the only one that sees the pattern, and `4 wrong guesses + 1 login to an account
319
- // the attacker owns, repeat` wiped it every fifth request. Measured: 5 guesses to
320
- // `X_ACCOUNT_LOCKED` without the reset, 160 and never locked with it.
318
+ if (!verification.ok || user === null) throw loginFailed();
319
+
320
+ // ONLY the account bucket is cleared, and only once nothing more is owed. The ACCOUNT window
321
+ // belongs to one person, so a success proves the typos before it were theirs. The address and
322
+ // the tenant count traffic from a SHARED source, so a success is not evidence the failures
323
+ // beside it were benign — clearing the address bucket made it inert against stuffing (`4 wrong
324
+ // guesses + 1 login to an account the attacker owns, repeat` never locked).
321
325
  //
322
- // The cost is a shared NAT accumulating failures from unrelated people, which is exactly what
323
- // `windowMs` bounds — and `X_ACCOUNT_LOCKED`'s `fix:` already names `recordSuccess(<key>)` as
324
- // the deliberate manual escape for that case.
326
+ // With a second factor still owed NOTHING is cleared: a caller holding the password would
327
+ // otherwise wipe the count of wrong codes with every repeat of this first leg.
328
+ if (user.mfaSecret === null) await attempt.succeed();
329
+ else await attempt.release();
325
330
 
326
331
  // Parameters were raised since this hash was written: upgrade it now, while we hold the
327
332
  // plaintext. This is the only moment it is possible without asking the user for anything.
@@ -331,11 +336,9 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
331
336
  });
332
337
  }
333
338
 
334
- // Password proven, second factor not. No half-authenticated session is written here and nothing
335
- // is persisted to correlate the two legs — finishing MFA is the app's, and `X_MFA_REQUIRED`'s
336
- // `fix:` is the instruction. The framework's own second leg is blocked on a sealed pending-MFA
337
- // credential; the constraint is written down in `packages/auth/CLAUDE.md`.
338
- if (user.mfaSecret !== null) throw mfaRequired(user.id);
339
+ // Password proven, second factor not. No half-authenticated session is written: the sealed
340
+ // challenge in the error is the only thing that carries the first leg to `completeMfa`.
341
+ if (user.mfaSecret !== null) throw await mfaChallengeRequired(auth, user.id);
339
342
 
340
343
  const issued = await createSession(auth.sessions, {
341
344
  userId: user.id,
@@ -0,0 +1,43 @@
1
+ // Single responsibility: an `x_users` unique violation as the code `MemoryAuthAdapter` answers. One
2
+ // duplicate signup must not be `X_AUTH_WRITE_FAILED` under `x dev` and `X_DB_UNIQUE_VIOLATION` in
3
+ // production: an app that branches on the code passes its tests and misses on Postgres.
4
+
5
+ import { stringField } from '@ultimat3/core';
6
+ import { SQLSTATE, sqlState } from '@ultimat3/db';
7
+ import { type AuthError, authUniqueViolation } from './errors';
8
+
9
+ /**
10
+ * The constraints `tables.ts` declares on `x_users`, by the names Postgres gives them. Closed: a
11
+ * unique index an app added to the table is the app's, and its violation travels on as the
12
+ * database error it is rather than being reported as a column it does not cover.
13
+ */
14
+ const X_USERS_UNIQUE_COLUMNS: ReadonlyMap<string, string> = new Map([
15
+ ['x_users_email_key', 'email'],
16
+ ['x_users_external_id_key', 'external_id'],
17
+ ['x_users_pkey', 'id'],
18
+ ]);
19
+
20
+ /** How deep `DbError.sourceError` may nest before the read stops — db's own bound. */
21
+ const MAX_WRAPS = 4;
22
+
23
+ /** The constraint the server named. db's wrap keeps the driver error on `sourceError`. */
24
+ function violatedConstraint(error: unknown): string | undefined {
25
+ let value = error;
26
+ for (let depth = 0; depth < MAX_WRAPS && value !== undefined && value !== null; depth += 1) {
27
+ const constraint = stringField(value, 'constraint');
28
+ if (constraint !== undefined) return constraint;
29
+ value = typeof value === 'object' ? (value as { sourceError?: unknown }).sourceError : null;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /**
35
+ * `undefined` for everything that is not one of `x_users`' own unique constraints, so the caller
36
+ * rethrows what it caught. The SQLSTATE is read through db's `sqlState`, which decides by where
37
+ * the error came from, never by the shape of a `code` field.
38
+ */
39
+ export function usersUniqueViolation(operation: string, error: unknown): AuthError | undefined {
40
+ if (sqlState(error) !== SQLSTATE.uniqueViolation) return undefined;
41
+ const column = X_USERS_UNIQUE_COLUMNS.get(violatedConstraint(error) ?? '');
42
+ return column === undefined ? undefined : authUniqueViolation(operation, 'x_users', column);
43
+ }