@ultimat3/auth 22.15.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/adapter.ts CHANGED
@@ -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`.
78
85
  */
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[]>;
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.
98
+ */
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 {
@@ -115,17 +144,15 @@ export interface SessionStore {
115
144
  /**
116
145
  * Every session this user holds, including the caller's own. `deleteOtherSessions` cannot
117
146
  * 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.
147
+ * Returns how many were killed.
122
148
  */
123
- deleteSessionsForUser?(userId: string): Promise<number>;
149
+ deleteSessionsForUser(userId: string): Promise<number>;
124
150
  /**
125
151
  * Everything issued before an instant. The credential-compromise sweep: rotate the secret,
126
- * then kill everything minted under the old one, without enumerating users.
152
+ * then kill everything minted under the old one, without enumerating users. Strictly before:
153
+ * a session created AT the instant survives.
127
154
  */
128
- deleteSessionsCreatedBefore?(before: Date): Promise<number>;
155
+ deleteSessionsCreatedBefore(before: Date): Promise<number>;
129
156
  }
130
157
 
131
158
  export interface AuthAccount {
@@ -218,5 +245,5 @@ export interface AuthAdapter
218
245
  * exactly the sessions it was run for. So it joins through `x_users`, which is why it sits on
219
246
  * the full adapter rather than on `SessionStore`.
220
247
  */
221
- deleteSessionsForOrg?(orgId: string): Promise<number>;
248
+ deleteSessionsForOrg(orgId: string): Promise<number>;
222
249
  }
@@ -0,0 +1,55 @@
1
+ // Single responsibility: a presented API key → who is calling, in the shape a bearer mount and an
2
+ // MCP endpoint both take as `resolveToken`. `verifyApiKey` answers a record or throws; this is the
3
+ // one place that turns its ONE rejection into the `null` those surfaces answer 401 for — and
4
+ // leaves everything else a throw, because "the database is down" is not "your key is wrong".
5
+
6
+ import { type Clock, isUltimateError, systemClock } from '@ultimat3/core';
7
+ import {
8
+ type ApiKeyActorOptions,
9
+ type ApiKeyVerifyStore,
10
+ apiKeyActor,
11
+ verifyApiKey,
12
+ } from './api-keys';
13
+ import type { PolicyActor } from './policy-bridge';
14
+
15
+ /**
16
+ * What a verified key resolves to. Structurally `@ultimat3/http`'s `BearerCaller` and
17
+ * `@ultimat3/mcp`'s `ResolvedToken` — declared here because this tier can import neither.
18
+ */
19
+ export interface ApiKeyCaller {
20
+ /** The agent actor: the key's org, and its scopes as the permissions a policy reads. */
21
+ readonly actor: PolicyActor;
22
+ /** The scopes the ACTOR carries — the key's, cut by its owner's grants. The mount's scope map is cut by these. */
23
+ readonly scopes: ReadonlySet<string>;
24
+ }
25
+
26
+ export interface ApiKeyResolverOptions extends ApiKeyActorOptions {
27
+ readonly clock?: Clock | undefined;
28
+ }
29
+
30
+ /**
31
+ * `resolveToken: apiKeyResolver(() => keys)` on `defineApi({ http: { mounts } })` and on
32
+ * `defineAppMcp()` — one resolver for both.
33
+ *
34
+ * `store` is a THUNK, read when a token is presented and never captured: a mount is declared when
35
+ * its module is evaluated, and `new BuiltinAdapter()` takes the process's database client, which
36
+ * boot installs later. `() => new MemoryAdapter()` would be a new, empty store per request — hold
37
+ * the instance and return it.
38
+ */
39
+ export function apiKeyResolver(
40
+ store: () => ApiKeyVerifyStore,
41
+ options: ApiKeyResolverOptions = {},
42
+ ): (token: string) => Promise<ApiKeyCaller | null> {
43
+ return async (token) => {
44
+ try {
45
+ const verified = await verifyApiKey(store(), token, options.clock ?? systemClock);
46
+ const actor = apiKeyActor(verified, options);
47
+ return { actor, scopes: new Set(actor.scopes) };
48
+ } catch (thrown) {
49
+ // Every way a key can be wrong is this one code, by `verifyApiKey`'s own design: a caller
50
+ // that could tell "revoked" from "unknown" could enumerate ids.
51
+ if (isUltimateError(thrown) && thrown.code === 'X_API_KEY_INVALID') return null;
52
+ throw thrown;
53
+ }
54
+ };
55
+ }
package/src/api-keys.ts CHANGED
@@ -5,10 +5,10 @@
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
7
  import { type Clock, randomHex, systemClock } from '@ultimat3/core';
8
- import type { ApiKeyStore, AuthApiKeyRecord } from './adapter';
9
- import { apiKeyEnvInvalid, apiKeyInvalid } from './errors';
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';
11
+ import { actorFromApiKey, isWildcardScope } from './policy-bridge';
12
12
  import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
13
13
 
14
14
  export const API_KEY_NAMESPACE = 'ult';
@@ -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
@@ -7,8 +7,11 @@ 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, 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 { createTotpReplayGuard, type TotpReplayGuard } from './mfa';
14
+ import { mfaChallengeRequired } from './mfa-challenge';
12
15
  import type { OAuthProviderId } from './oauth';
13
16
  import {
14
17
  checkPasswordStrength,
@@ -26,9 +29,7 @@ import {
26
29
  assertAuthLimiterPolicy,
27
30
  createAuthLimiter,
28
31
  DEFAULT_AUTH_RATE_LIMIT,
29
- ipKey,
30
32
  loginFailed,
31
- orgKey,
32
33
  orgRateLimit,
33
34
  } from './rate-limit';
34
35
  import {
@@ -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
+ * `createTotpReplayGuard()` — 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
  }
@@ -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 ?? createTotpReplayGuard(),
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
@@ -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 `MemoryAdapter` 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
+ }
@@ -3,6 +3,7 @@
3
3
  // drive it without a database. Rows arrive as `unknown` and are read through the small typed
4
4
  // readers below — no `any`, and a column rename fails loudly instead of producing `undefined`.
5
5
 
6
+ import { type Clock, systemClock } from '@ultimat3/core';
6
7
  import { type DbClient, db, sql } from '@ultimat3/db';
7
8
  import type {
8
9
  AuthAccount,
@@ -13,9 +14,11 @@ import type {
13
14
  AuthVerification,
14
15
  CreateUserInput,
15
16
  SessionPatch,
17
+ StoredMfaSecret,
16
18
  UserPatch,
17
19
  UserQuery,
18
20
  } from './adapter';
21
+ import { usersUniqueViolation } from './builtin-adapter-violation';
19
22
  import { authWriteFailed } from './errors';
20
23
 
21
24
  type Row = Readonly<Record<string, unknown>>;
@@ -117,9 +120,24 @@ const toApiKey = (row: Row): AuthApiKeyRecord => ({
117
120
  export class BuiltinAdapter implements AuthAdapter {
118
121
  readonly name = 'builtin-postgres';
119
122
  readonly #db: DbClient;
123
+ readonly #clock: Clock;
120
124
 
121
- constructor(client: DbClient = db()) {
125
+ /**
126
+ * `clock` is the one `MemoryAdapter` takes, for the same stamp: hand over the clock
127
+ * `defineAuth` was given, so a redemption is dated by the clock that judged its expiry.
128
+ */
129
+ constructor(client: DbClient = db(), clock: Clock = systemClock) {
122
130
  this.#db = client;
131
+ this.#clock = clock;
132
+ }
133
+
134
+ /** A write to `x_users`, with the table's own unique violations answered as this package's. */
135
+ async #writeUser(operation: string, statement: Parameters<DbClient['one']>[0]) {
136
+ try {
137
+ return await this.#db.one<Row>(statement);
138
+ } catch (error) {
139
+ throw usersUniqueViolation(operation, error) ?? error;
140
+ }
123
141
  }
124
142
 
125
143
  async findUserByEmail(email: string): Promise<AuthUser | null> {
@@ -133,13 +151,16 @@ export class BuiltinAdapter implements AuthAdapter {
133
151
  }
134
152
 
135
153
  async createUser(input: CreateUserInput): Promise<AuthUser> {
136
- const row = await this.#db.one<Row>(sql`
154
+ const row = await this.#writeUser(
155
+ 'createUser',
156
+ sql`
137
157
  insert into x_users (id, email, password_hash, org_id, roles, scopes, external_id,
138
158
  created_at)
139
159
  values (${input.id}, ${input.email}, ${input.passwordHash}, ${input.orgId},
140
160
  ${[...input.roles]}, ${[...(input.scopes ?? [])]}, ${input.externalId ?? null},
141
161
  ${input.createdAt})
142
- returning *`);
162
+ returning *`,
163
+ );
143
164
  // An empty `returning` means no row landed. A user fabricated from `{}` would travel back
144
165
  // out of `register()` as a successful registration with no identity in it.
145
166
  if (row === null) throw authWriteFailed('createUser', 'x_users');
@@ -147,7 +168,9 @@ export class BuiltinAdapter implements AuthAdapter {
147
168
  }
148
169
 
149
170
  async updateUser(id: string, patch: UserPatch): Promise<AuthUser | null> {
150
- const row = await this.#db.one<Row>(sql`
171
+ const row = await this.#writeUser(
172
+ 'updateUser',
173
+ sql`
151
174
  update x_users set
152
175
  password_hash = case when ${patch.passwordHash !== undefined}
153
176
  then ${patch.passwordHash ?? null} else password_hash end,
@@ -170,10 +193,39 @@ export class BuiltinAdapter implements AuthAdapter {
170
193
  external_id = case when ${patch.externalId !== undefined}
171
194
  then ${patch.externalId ?? null} else external_id end
172
195
  where id = ${id}
173
- returning *`);
196
+ returning *`,
197
+ );
174
198
  return row === null ? null : toUser(row);
175
199
  }
176
200
 
201
+ async listUsersWithMfaSecret(): Promise<readonly StoredMfaSecret[]> {
202
+ const rows = await this.#db.query<Row>(
203
+ sql`select id, mfa_secret from x_users where mfa_secret is not null order by id`,
204
+ );
205
+ return rows.map((row) => ({ userId: text(row, 'id'), mfaSecret: text(row, 'mfa_secret') }));
206
+ }
207
+
208
+ async replaceMfaSecret(userId: string, expected: string, next: string): Promise<boolean> {
209
+ const row = await this.#db.one<Row>(sql`
210
+ update x_users set mfa_secret = ${next}
211
+ where id = ${userId} and mfa_secret = ${expected}
212
+ returning id`);
213
+ return row !== null;
214
+ }
215
+
216
+ /**
217
+ * One conditional UPDATE: the row is locked, and the `where` is re-checked against the version
218
+ * a concurrent redemption just committed, so the second of two finds the hash already gone.
219
+ */
220
+ async consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
221
+ const row = await this.#db.one<Row>(sql`
222
+ update x_users
223
+ set recovery_code_hashes = array_remove(recovery_code_hashes, ${codeHash})
224
+ where id = ${userId} and ${codeHash} = any(recovery_code_hashes)
225
+ returning id`);
226
+ return row !== null;
227
+ }
228
+
177
229
  async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
178
230
  const row = await this.#db.one<Row>(
179
231
  sql`select * from x_users where external_id = ${externalId}`,
@@ -308,6 +360,9 @@ export class BuiltinAdapter implements AuthAdapter {
308
360
  }
309
361
 
310
362
  /**
363
+ * `consumed_at` is the injected clock's instant, bound — never the server's `now()`, which made
364
+ * the stamp the one instant in this package no test could move and no `Clock` agreed with.
365
+ *
311
366
  * One conditional UPDATE, and every part of the predicate is load-bearing. `consumed_at is null`
312
367
  * on the UPDATE itself is what makes redemption single-use under concurrency — inside the
313
368
  * subselect alone, two racing redemptions both pick the row and both consume it. `token_hash`
@@ -324,7 +379,7 @@ export class BuiltinAdapter implements AuthAdapter {
324
379
  tokenHash: string,
325
380
  ): Promise<AuthVerification | null> {
326
381
  const row = await this.#db.one<Row>(sql`
327
- update x_verifications set consumed_at = now()
382
+ update x_verifications set consumed_at = ${this.#clock.now()}
328
383
  where consumed_at is null and id = (
329
384
  select id from x_verifications
330
385
  where purpose = ${purpose} and identifier = ${identifier}
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { Actor } from '@ultimat3/core';
7
7
  import { logger } from '@ultimat3/core';
8
- import { clientScopeSecretShort } from './errors';
8
+ import { AuthError } from './errors';
9
9
 
10
10
  /** 128 bits: a scope only has to differ between principals, never to authenticate anyone. */
11
11
  const SCOPE_HEX_CHARS = 32;
@@ -46,7 +46,13 @@ function scopeKey(options: ClientScopeOptions): string {
46
46
  // The same floor as the env path below: an explicit key is a caller's value, so a short one is
47
47
  // refused rather than silently swapped for the process key.
48
48
  if (options.secret.length < MIN_SECRET_LENGTH) {
49
- throw clientScopeSecretShort(options.secret.length, MIN_SECRET_LENGTH);
49
+ // Never its length: how long the key handed over is narrows a guess at it.
50
+ throw new AuthError({
51
+ code: 'X_CONFIG_INVALID',
52
+ cause: `clientScopeOf({ secret }) was passed a key shorter than the ${String(MIN_SECRET_LENGTH)} characters required, so the scope id would be keyed by a guessable secret`,
53
+ fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
54
+ meta: { option: 'secret', minLength: MIN_SECRET_LENGTH },
55
+ });
50
56
  }
51
57
  return options.secret;
52
58
  }
package/src/directory.ts CHANGED
@@ -6,7 +6,6 @@
6
6
 
7
7
  import type { AuthUser, UserQuery } from './adapter';
8
8
  import type { Auth } from './auth';
9
- import { authNotImplemented } from './errors';
10
9
 
11
10
  /** Safe to render in an admin page, return from an MCP tool, or paste into a review ticket. */
12
11
  export interface AuthUserSummary {
@@ -45,21 +44,13 @@ export function describeUser(user: AuthUser): AuthUserSummary {
45
44
  };
46
45
  }
47
46
 
48
- const unsupported = (method: string, adapterName: string) =>
49
- authNotImplemented(
50
- `${adapterName}.${method}()`,
51
- `implement ${method}() on your AuthAdapter — BuiltinAdapter (Postgres) and MemoryAdapter are the two reference implementations, in packages/auth/src`,
52
- );
53
-
54
47
  /** Every member of one org, as summaries. `query.role` narrows it to one role's holders. */
55
48
  export async function listOrgUsers(
56
49
  auth: Auth,
57
50
  orgId: string,
58
51
  query?: UserQuery,
59
52
  ): Promise<readonly AuthUserSummary[]> {
60
- const list = auth.adapter.listUsersByOrg?.bind(auth.adapter);
61
- if (list === undefined) throw unsupported('listUsersByOrg', auth.adapter.name);
62
- return (await list(orgId, query)).map(describeUser);
53
+ return (await auth.adapter.listUsersByOrg(orgId, query)).map(describeUser);
63
54
  }
64
55
 
65
56
  /**
@@ -71,7 +62,5 @@ export async function findUserByExternalId(
71
62
  auth: Auth,
72
63
  externalId: string,
73
64
  ): Promise<AuthUser | null> {
74
- const find = auth.adapter.findUserByExternalId?.bind(auth.adapter);
75
- if (find === undefined) throw unsupported('findUserByExternalId', auth.adapter.name);
76
- return await find(externalId);
65
+ return await auth.adapter.findUserByExternalId(externalId);
77
66
  }