@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.
@@ -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>>;
@@ -114,12 +117,27 @@ const toApiKey = (row: Row): AuthApiKeyRecord => ({
114
117
  * assembled into dynamic SQL: `null` stays a meaningful value ("clear this field") and the
115
118
  * statement text remains constant, so the query plan is cached and nothing is interpolated.
116
119
  */
117
- export class BuiltinAdapter implements AuthAdapter {
120
+ export class PostgresAuthAdapter 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 `MemoryAuthAdapter` 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}`,
@@ -185,7 +237,9 @@ export class BuiltinAdapter implements AuthAdapter {
185
237
  * One statement, and the two filters are conditional predicates rather than assembled SQL, for
186
238
  * the reason `updateUser`'s columns are: the statement text stays constant and nothing is
187
239
  * interpolated. Disabled members are excluded by default — an access review reads who can sign
188
- * in today — and `includeDisabled` is what an offboarding audit passes.
240
+ * in today — and `includeDisabled` is what an offboarding audit passes. Ordered `collate "C"`
241
+ * (byte order, which `MemoryAuthAdapter` restates): the database default collation is the
242
+ * cluster's locale, so the order changed with the deployment and disagreed with memory's.
189
243
  */
190
244
  async listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]> {
191
245
  const role = query?.role ?? null;
@@ -194,7 +248,7 @@ export class BuiltinAdapter implements AuthAdapter {
194
248
  where org_id = ${orgId}
195
249
  and (${query?.includeDisabled === true} or disabled_at is null)
196
250
  and (${role === null} or ${role} = any (roles))
197
- order by email asc`);
251
+ order by email collate "C" asc`);
198
252
  return rows.map(toUser);
199
253
  }
200
254
 
@@ -308,6 +362,9 @@ export class BuiltinAdapter implements AuthAdapter {
308
362
  }
309
363
 
310
364
  /**
365
+ * `consumed_at` is the injected clock's instant, bound — never the server's `now()`, which made
366
+ * the stamp the one instant in this package no test could move and no `Clock` agreed with.
367
+ *
311
368
  * One conditional UPDATE, and every part of the predicate is load-bearing. `consumed_at is null`
312
369
  * on the UPDATE itself is what makes redemption single-use under concurrency — inside the
313
370
  * subselect alone, two racing redemptions both pick the row and both consume it. `token_hash`
@@ -324,7 +381,7 @@ export class BuiltinAdapter implements AuthAdapter {
324
381
  tokenHash: string,
325
382
  ): Promise<AuthVerification | null> {
326
383
  const row = await this.#db.one<Row>(sql`
327
- update x_verifications set consumed_at = now()
384
+ update x_verifications set consumed_at = ${this.#clock.now()}
328
385
  where consumed_at is null and id = (
329
386
  select id from x_verifications
330
387
  where purpose = ${purpose} and identifier = ${identifier}
@@ -367,3 +424,14 @@ export class BuiltinAdapter implements AuthAdapter {
367
424
  return changed > 0;
368
425
  }
369
426
  }
427
+
428
+ /**
429
+ * The one way to build the Postgres adapter — the twin of `memoryAuthAdapter()`. The class is a
430
+ * type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
431
+ */
432
+ export function postgresAuthAdapter(
433
+ client: DbClient = db(),
434
+ clock: Clock = systemClock,
435
+ ): PostgresAuthAdapter {
436
+ return new PostgresAuthAdapter(client, clock);
437
+ }
@@ -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
  }
package/src/email.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: the ONE normalisation an email address gets before anything uses it as an
2
2
  // identity key — a user lookup, a user insert, or the lockout bucket that has to key the same way
3
3
  // those two do. It lives above the `AuthAdapter` seam because an adapter that normalises hides a
4
- // caller that forgot to: `MemoryAdapter` did and `BuiltinAdapter` did not, so "does this account
4
+ // caller that forgot to: `MemoryAuthAdapter` did and `PostgresAuthAdapter` did not, so "does this account
5
5
  // exist" had two answers, one under `x dev` and another in production.
6
6
 
7
7
  /**
package/src/errors.ts CHANGED
@@ -29,6 +29,7 @@ export const AUTH_OWNED_ERROR_CODES = [
29
29
  'X_AUTH_WRITE_FAILED',
30
30
  'X_AUTH_LIMITER_NOT_SHARED',
31
31
  'X_AUTH_LIMITER_POLICY_MISMATCH',
32
+ 'X_MFA_SECRET_UNSEALED',
32
33
  ] as const;
33
34
 
34
35
  /**
@@ -74,6 +75,7 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
74
75
  X_AUTH_WRITE_FAILED: 'an adapter write returned no row, so it cannot be confirmed',
75
76
  X_AUTH_LIMITER_NOT_SHARED: 'the lockout is declared fleet-wide and the limiter is per-process',
76
77
  X_AUTH_LIMITER_POLICY_MISMATCH: 'the limiter in use enforces other numbers than the app declared',
78
+ X_MFA_SECRET_UNSEALED: 'a stored totp secret is not sealed, so it is never read as one',
77
79
  };
78
80
 
79
81
  // Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
@@ -115,14 +117,14 @@ export class AuthError extends UltimateError {
115
117
  * `authenticate()` returns the anonymous actor for a missing cookie rather than throwing, which
116
118
  * is correct — and is exactly why the failure surfaces here, one layer later, instead of there.
117
119
  */
118
- export const unauthenticated = (surface: string): AuthError =>
120
+ export const authUnauthenticated = (surface: string): AuthError =>
119
121
  new AuthError({
120
122
  code: 'X_UNAUTHENTICATED',
121
123
  cause: `${surface} needs an actor but ctx.actor is anonymous`,
122
124
  fix: 'send the __Host-x_session cookie with this request, and resolve it once at the boundary: authenticate(auth, readSessionCookie(request, auth.sessions.policy))',
123
125
  });
124
126
 
125
- export const forbidden = (surface: string, reason: string): AuthError =>
127
+ export const authForbidden = (surface: string, reason: string): AuthError =>
126
128
  new AuthError({
127
129
  code: 'X_FORBIDDEN',
128
130
  cause: `${surface} denied: ${reason}`,
@@ -162,23 +164,41 @@ export const kdfOverloaded = (active: number, queued: number): AuthError =>
162
164
  });
163
165
 
164
166
  /**
165
- * The second leg is the APP's, and this line says so — it named `POST /auth/mfa/verify` for a
166
- * release while no such route, no `completeMfa()` and no pending-MFA credential existed anywhere,
167
- * the same dead-`fix:` defect `oauth-paths.ts` exists to stop. Shipping that route from here would
168
- * be worse than saying nothing: the only correlation value this error carries is a user id, so the
169
- * route would be unauthenticated by construction and MFA would become the ONLY factor. The design
170
- * constraint for the real second leg is in `packages/auth/CLAUDE.md`.
167
+ * The second leg is `completeMfa(auth, challenge, code)` (`mfa-challenge.ts`), and the only thing
168
+ * that correlates it with a passed first leg is `meta.challenge`: a sealed, short-lived value the
169
+ * sign-in handler carries to the caller and back. It replaces a bare user id, which made any
170
+ * second leg built on it unauthenticated by construction.
171
171
  *
172
- * `userId` is `meta`, never `cause`: both surfaces that render this code to an anonymous caller
173
- * (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause` and drop
174
- * `meta`, and a user id handed to whoever typed the URL is what feeds the attack above.
172
+ * The challenge is `meta`, never `cause`: both surfaces that render this code to an anonymous
173
+ * caller (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause`
174
+ * and drop `meta`, so the handler decides where it travels — a cookie or its own response body.
175
175
  */
176
- export const mfaRequired = (userId: string): AuthError =>
176
+ export const mfaRequired = (challenge: string): AuthError =>
177
177
  new AuthError({
178
178
  code: 'X_MFA_REQUIRED',
179
179
  cause: 'this account has TOTP enrolled and the second factor has not been satisfied',
180
- fix: 'no second-factor route ships yet — catch X_MFA_REQUIRED in your sign-in handler, check the code with verifyTotp({ secret: user.mfaSecret, code, at }), then mint the session with createSession(auth.sessions, { userId, mfaSatisfied: true })',
181
- meta: { userId },
180
+ fix: 'catch X_MFA_REQUIRED in your sign-in handler, hand error.meta.challenge to the caller, and finish with: await completeMfa(auth, challenge, code)',
181
+ meta: { challenge },
182
+ });
183
+
184
+ /**
185
+ * A challenge `completeMfa` will not act on: not one this app sealed, past its expiry, or naming
186
+ * an account that can no longer finish (deleted, disabled, or no longer enrolled). One refusal
187
+ * for all of them — which it was is not the caller's to learn.
188
+ */
189
+ export const mfaChallengeInvalid = (): AuthError =>
190
+ new AuthError({
191
+ code: 'X_UNAUTHENTICATED',
192
+ cause: 'the second-factor challenge is not one this app issued, or it is no longer usable',
193
+ fix: 'sign in again for a fresh challenge: await login(auth, { email, password })',
194
+ });
195
+
196
+ /** A wrong or already-spent code. The attempt it cost stays counted against the account. */
197
+ export const mfaCodeRejected = (): AuthError =>
198
+ new AuthError({
199
+ code: 'X_UNAUTHENTICATED',
200
+ cause: 'the second-factor code did not match, or it was already used',
201
+ fix: 'read the current code from the authenticator app and retry: await completeMfa(auth, challenge, code)',
182
202
  });
183
203
 
184
204
  /**
@@ -201,6 +221,19 @@ export const mfaSecretInvalid = (surface: string): AuthError =>
201
221
  fix: 'issue a fresh one and store it: const { secret } = enrolTotp(auth, { account: user.email }) — an imported secret must be base32 (A-Z and 2-7, padding, spaces and dashes ignored) and decode to at least one byte',
202
222
  });
203
223
 
224
+ /**
225
+ * A stored secret that is not sealed. It is refused rather than read: the plaintext path is the
226
+ * thing sealing removes, and a reader that fell back to it would keep it alive for good. The fix
227
+ * is the one-shot that seals every such row; `x doctor` reports the count before a user does.
228
+ */
229
+ export const mfaSecretUnsealed = (): AuthError =>
230
+ new AuthError({
231
+ code: 'X_MFA_SECRET_UNSEALED',
232
+ cause:
233
+ 'x_users.mfa_secret holds a value that is not sealed — written before second-factor secrets were sealed at rest, or written past saveTotpSecret — and a plaintext secret is never read as one',
234
+ fix: 'x auth seal-mfa --json',
235
+ });
236
+
204
237
  /**
205
238
  * `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
206
239
  * security guarantee — "this deployment requires a second factor" — and nothing in this package
@@ -246,23 +279,10 @@ export const authPolicyNumberInvalid = (
246
279
  meta: { option: key, value: String(value) },
247
280
  });
248
281
 
249
- /**
250
- * An explicit key for `clientScopeOf` under the floor `SESSION_SECRET` is held to. The env path
251
- * already refused a short secret; the option skipped the screen, so `{ secret: 'x' }` keyed every
252
- * page's scope id with one character. `X_CONFIG_INVALID`, borrowed like the two above.
253
- */
254
- export const clientScopeSecretShort = (length: number, minLength: number): AuthError =>
255
- new AuthError({
256
- code: 'X_CONFIG_INVALID',
257
- cause: `clientScopeOf({ secret }) was passed a key of ${String(length)} characters and at least ${String(minLength)} are required, so the scope id would be keyed by a guessable secret`,
258
- fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
259
- meta: { option: 'secret', minLength },
260
- });
261
-
262
282
  /**
263
283
  * An api key `env` the parser cannot read back. `parseApiKey` splits `ult_<env>_<id>_<secret>` on
264
284
  * `_`, so `issueApiKey({ env: 'live_eu' })` minted a key its own verifier refused. Refused at
265
- * issue — `X_CONFIG_INVALID`, borrowed like the three above — never at first use.
285
+ * issue — `X_CONFIG_INVALID`, borrowed like the two above — never at first use.
266
286
  */
267
287
  export const apiKeyEnvInvalid = (env: string): AuthError =>
268
288
  new AuthError({
@@ -274,6 +294,19 @@ export const apiKeyEnvInvalid = (env: string): AuthError =>
274
294
  meta: { option: 'env' },
275
295
  });
276
296
 
297
+ /**
298
+ * A wildcard scope on an api key. `*` and `<resource>:*` are how a ROLE is granted everything; on
299
+ * a key they are a credential that silently grows with every permission the app adds later.
300
+ * Refused at issue — `X_CONFIG_INVALID`, borrowed like the ones above.
301
+ */
302
+ export const apiKeyScopeWildcard = (scope: string): AuthError =>
303
+ new AuthError({
304
+ code: 'X_CONFIG_INVALID',
305
+ cause: `api key scope ${renderCauseValue(scope)} is a wildcard, and a key carries exactly the permissions it names`,
306
+ fix: "pass each permission by name to issueApiKey({ scopes }) — e.g. scopes: ['post:read', 'post:publish']",
307
+ meta: { option: 'scopes' },
308
+ });
309
+
277
310
  export const passwordWeak = (reasons: readonly string[]): AuthError =>
278
311
  new AuthError({
279
312
  code: 'X_PASSWORD_WEAK',
@@ -281,28 +314,35 @@ export const passwordWeak = (reasons: readonly string[]): AuthError =>
281
314
  fix: 'choose a longer, uncommon password — or relax defineAuth({ password: { minLength } })',
282
315
  });
283
316
 
317
+ /** The tenant bucket's key prefix. Declared here because the refusal below must recognise it. */
318
+ export const ORG_KEY_PREFIX = 'org:';
319
+
284
320
  /**
285
321
  * The escape is `recordSuccess(key)`, which is what a successful login already calls: it deletes
286
322
  * the bucket, so it clears exactly this one key and nothing else. `reset()` exists too and is the
287
323
  * wrong reach — it drops every bucket in the table, including the spray this lockout is holding.
288
324
  *
289
- * `key` carries an address or an email the caller chose, so it goes through `renderFixLiteral`:
290
- * a fix line has to still parse after a hostile value lands in it.
325
+ * An `ip:` or `account:` key is the caller's own input, so it is echoed (escaped). A tenant key is
326
+ * NOT: the org id was looked up from the address the caller typed, and naming it — or even saying
327
+ * "an organisation" — confirms to an anonymous caller that the address belongs to a member.
291
328
  */
292
- export const accountLocked = (key: string, retryAfterSeconds: number): AuthError =>
293
- new AuthError({
329
+ export const accountLocked = (key: string, retryAfterSeconds: number): AuthError => {
330
+ const tenant = key.startsWith(ORG_KEY_PREFIX);
331
+ return new AuthError({
294
332
  code: 'X_ACCOUNT_LOCKED',
295
- // `renderCauseValue`, matching `oauthDenied`: `ipKey(ip)` builds this key from whatever address
296
- // string its caller passed, so a newline in it writes a second log line an operator reads as
297
- // genuine. The value is `string` by type — the static scan only sees `unknown`/`any`, so this
298
- // one was never going to be caught for us — and rendering it as a JSON string literal is
299
- // escaping, not throw-safety.
300
- cause: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
301
- fix: `wait ${retryAfterSeconds}s — or clear this one bucket: auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}), auth.orgLimiter for an org: key — or raise defineAuth({ rateLimit })`,
333
+ // `renderCauseValue`: `ipKey(ip)` builds the key from whatever address string its caller
334
+ // passed, so a newline in it would write a second log line an operator reads as genuine.
335
+ cause: tenant
336
+ ? `sign-in is locked out for another ${retryAfterSeconds}s after repeated failures`
337
+ : `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
338
+ fix: tenant
339
+ ? `wait ${retryAfterSeconds}s — or clear the one bucket that is holding: await auth.orgLimiter.recordSuccess(orgKey(user.orgId)) — or raise defineAuth({ rateLimit })`
340
+ : `wait ${retryAfterSeconds}s — or clear this one bucket: await auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}) — or raise defineAuth({ rateLimit })`,
302
341
  // `kdfOverloaded`'s shape: `@ultimat3/http`'s `retryAfterOf` reads exactly this field. `key`
303
342
  // stays out — `cause`/`fix` escape it on purpose and `meta` is read by surfaces that do not.
304
343
  meta: { retryAfterSeconds },
305
344
  });
345
+ };
306
346
 
307
347
  /** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
308
348
  export const apiKeyInvalid = (): AuthError =>
@@ -332,7 +372,7 @@ export const authWriteFailed = (operation: string, table: string): AuthError =>
332
372
  * the same failure to an operator — the row is not there and the caller must not assume it is —
333
373
  * and the cause says which of the two happened.
334
374
  *
335
- * `MemoryAdapter` enforced neither `x_users.email` nor `x_users.external_id`, while `BuiltinAdapter`
375
+ * `MemoryAuthAdapter` enforced neither `x_users.email` nor `x_users.external_id`, while `PostgresAuthAdapter`
336
376
  * leans on both: two `register()` calls at one address made TWO rows in memory, and the second was
337
377
  * unreachable forever because `findUserByEmail` returns the first. That adapter is what `x new`
338
378
  * scaffolds and what every test runs against, so the duplicate path was only exercised against the
@@ -376,11 +416,3 @@ export const authLimiterPolicyMismatch = (
376
416
  fix: `construct the limiter with ${field}: ${declared} — defineAuth({ rateLimit, limiter }) compares the two, and the declaration is what Auth.rateLimit reports`,
377
417
  meta: { field, declared, enforced },
378
418
  });
379
-
380
- /** For a custom `AuthAdapter` that implements part of the seam. Nothing shipped throws it. */
381
- export const authNotImplemented = (feature: string, fix: string): AuthError =>
382
- new AuthError({
383
- code: 'X_NOT_IMPLEMENTED',
384
- cause: `${feature} is not implemented by the built-in driver`,
385
- fix,
386
- });
package/src/guards.ts CHANGED
@@ -5,14 +5,14 @@
5
5
 
6
6
  import type { Actor } from '@ultimat3/core';
7
7
  import { isAnonymous, useContext } from '@ultimat3/core';
8
- import { unauthenticated } from './errors';
8
+ import { authUnauthenticated } from './errors';
9
9
 
10
10
  const DEFAULT_SURFACE = 'this request';
11
11
 
12
12
  /** Throws `X_UNAUTHENTICATED` when the ambient actor is anonymous. */
13
13
  export function requireActor(surface: string = DEFAULT_SURFACE): Actor {
14
14
  const { actor } = useContext();
15
- if (isAnonymous(actor)) throw unauthenticated(surface);
15
+ if (isAnonymous(actor)) throw authUnauthenticated(surface);
16
16
  return actor;
17
17
  }
18
18
 
package/src/id-token.ts CHANGED
@@ -6,13 +6,12 @@
6
6
  // default is what silently makes the second door as trusting as the first.
7
7
 
8
8
  import type { Clock } from '@ultimat3/core';
9
- import { renderCauseValue } from '@ultimat3/core';
9
+ import { renderCauseValue, timingSafeEqual } from '@ultimat3/core';
10
10
  import { decodeJwtSegment } from './json';
11
11
  import { type IdTokenKeys, verifyJwtSignature } from './jwks';
12
12
  import type { OAuthProvider, OAuthProviderId } from './oauth';
13
13
  import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './oauth-errors';
14
14
  import { providerFor } from './oauth-registry';
15
- import { timingSafeEqual } from './tokens';
16
15
 
17
16
  /** The subset of OIDC claims this package acts on. Provider-specific extras are ignored. */
18
17
  export interface IdTokenClaims {
@@ -128,7 +127,7 @@ export interface VerifyIdTokenInput {
128
127
  * Required, and there is deliberately no default. `'token-endpoint-tls'` asserts this token was
129
128
  * read off a TLS response from the provider's own token endpoint — the OIDC Core 3.1.3.7 case,
130
129
  * and the only one where an unverified JWT is safe. Every other channel passes a key source:
131
- * `providerJwks(providerFor(id))`, or a `createJwksClient({ jwksUri })` of its own.
130
+ * `providerJwks(providerFor(id))`, or a `jwksClient({ jwksUri })` of its own.
132
131
  */
133
132
  readonly keys: IdTokenKeys;
134
133
  }