@ultimat3/auth 9.0.0 → 10.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/CLAUDE.md CHANGED
@@ -202,7 +202,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
202
202
  provider, so it is unrepresentable rather than discouraged. An app that wants it wraps
203
203
  `signInWithOAuth`.
204
204
  - **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is
205
- read by both `errors.ts` and `oauth-route.ts`, so a `fix:` line naming
205
+ read by both `oauth-errors.ts` and `oauth-route.ts`, so a `fix:` line naming
206
206
  `GET /auth/oauth/<provider>` cannot outlive the route again — which is exactly what it did
207
207
  through 1.2.0, when the library functions shipped with no route to mount them in. Every
208
208
  "start over" fix is built from `restartAt(provider)`.
@@ -214,9 +214,60 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
214
214
  implementation of the open-redirect check and a second copy is one that drifts.
215
215
  - The handshake cookie is cleared on **every** callback outcome, success and failure alike:
216
216
  the code it authorised is spent either way.
217
- - Refresh is **not implemented**. `AuthAccount` persists `refreshToken` and `expiresAt`, and
218
- nothing reads them yet the session is the framework's own credential and does not depend
219
- on the provider token.
217
+ - Refresh is **not implemented**, and the framework therefore **stores no provider token** (`As of
218
+ 2026-08-23`). `accountFor` (`oauth-login.ts`) writes `accessToken: null, refreshToken: null`:
219
+ nothing in this package ever read either column back — `oauth-profile.ts` uses the token from the
220
+ exchange, in flight — while `tables.ts`'s header promised "no column holds a plaintext secret".
221
+ Declared and never wired, the deletion this repo already ran for `jobs.driver`, except this one
222
+ turned a database dump into a set of usable third-party credentials. The COLUMNS and the
223
+ `AuthAccount` fields both stay: the type is the documented adapter seam, and an app that
224
+ deliberately stores tokens implements `linkAccount` itself. `expiresAt` is still written.
225
+ - **`defineAuth({ providers })` defaults to `[]`**, never the live registry (`As of 2026-08-23`,
226
+ BREAKING). It was `oauthProviderIds()`, so nothing was ever "left out" and the uniform-404 the
227
+ option exists for could not fire — while a registry any dependency writes into decided which
228
+ login endpoints the app served. Name what you enabled.
229
+ - **A provider with no credentials answers the SAME 404 an unknown one does.** Past `assertEnabled`,
230
+ `oauthCredentials` threw `X_ENV_MISSING` and the route published a 500 carrying the app's own env
231
+ var names, so `500 = registered here, 404 = not` re-opened the oracle one request later.
232
+ `credentialsFor` (`oauth-route.ts`) catches it on BOTH legs, logs the real cause under
233
+ `auth.oauth.credentials_missing`, and re-throws `oauthProviderUnknown`.
234
+ - **Nothing this package does not own reaches a published `cause:`.** Both legs of the OAuth flow
235
+ are anonymous by definition and `publicBody` serialises `cause`. Two paths carried an internal
236
+ message into it and both now log instead: an uncoded throw from an adapter (`uncoded()` in
237
+ `oauth-route.ts` → `auth.oauth.uncoded_failure`) and a rejecting `OAuthFetch`
238
+ (`postForm` → `auth.oauth.token_fetch_failed`). The `POST /token` leg also reads its response
239
+ body as `providerDetail(response, 'coded-only')` — that request carries `client_secret`, and an
240
+ echoing endpoint put 38 of 42 characters of it into a 502 body. `error`/`error_description` still
241
+ come through; userinfo, discovery and jwks keep the raw fallback, because their requests carry
242
+ no secret.
243
+ - **`x_users.mfa_secret` is a PLAINTEXT secret and `tables.ts` now says so, column by column.** A
244
+ TOTP seed is symmetric, so a digest cannot verify a code; encrypting it needs a key-management
245
+ seam this package does not have. **Deferred deliberately** — `mfa.ts`'s "a database dump is not a
246
+ permanent MFA bypass" is true of the recovery CODES and not of the seed.
247
+ - **`issueVerification`/`consumeVerification` normalise too** — the fourth identity door.
248
+ `putVerification` upserts on `(purpose, identifier)` and `adapter.ts` promises a new token
249
+ invalidates the previous one; that promise was per SPELLING, so N live reset tokens for one
250
+ address could be held at once by varying the case, and each is a password.
251
+ - **`providerJwks` memoises only the DEFAULT client.** The memo was keyed on the provider id alone
252
+ and silently discarded a later caller's options, so an app pinning a corporate egress proxy got
253
+ it only if it called first — the `jobs.driver` shape, with a network path as the substituted
254
+ value. A caller that supplies options gets its own client.
255
+ - **`verifyIdToken` checks `nbf` and `azp`.** `workload.ts` imports `ID_TOKEN_CLOCK_SKEW_MS` from
256
+ `id-token.ts` and then enforced a bound `id-token.ts` did not: an `nbf` ten years out verified.
257
+ `azp` is OIDC Core 3.1.3.7 step 5 — with more than one audience, `aud` naming this client says
258
+ only that the token MENTIONS it. Both only narrow what is accepted.
259
+ - **A success clears the ACCOUNT bucket and nothing else.** `recordSuccess(ipKey(ip))` used to run
260
+ on every login and deleted the whole address bucket, which made it inert against the attack it
261
+ exists for: a stuffing run never spends `maxAttempts` guesses on one account, so
262
+ `4 wrong + 1 login to an account the attacker owns, repeat` never locked. Measured: 5 guesses to
263
+ `X_ACCOUNT_LOCKED` without the reset, 160 and unlocked with it. The cost is a shared NAT
264
+ accumulating failures, which is what `windowMs` bounds and what `X_ACCOUNT_LOCKED`'s `fix:`
265
+ already names the manual escape for.
266
+ - **`MemoryAdapter.createUser` enforces the two UNIQUE constraints `BuiltinAdapter` leans on** —
267
+ `x_users.email` and `x_users.external_id`, `authUniqueViolation` (`X_AUTH_WRITE_FAILED`). It is
268
+ what `x new` scaffolds and what every test runs against, so the duplicate path was only ever
269
+ exercised against the permissive half of the seam: two `register()` calls at one address made two
270
+ rows, and the second was unreachable forever. `adapter-parity.test.ts` pins both halves.
220
271
  - The new `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
221
272
  `deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`). A required
222
273
  member is a breaking change to every third-party adapter; the callers throw
@@ -329,6 +380,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
329
380
  | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
330
381
  | `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
331
382
  | `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
383
+ | `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
384
+ | `oauth-errors.ts` | the OAuth half of those factories, and `restartAt`. Split off at the 500-line ceiling; declares no code and registers nothing |
332
385
  | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
333
386
  | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it |
334
387
  | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
package/README.md CHANGED
@@ -12,7 +12,7 @@ export const auth = defineAuth({
12
12
  session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
13
13
  password: { minLength: 12 },
14
14
  mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
15
- providers: ['github', 'google'],
15
+ providers: ['github', 'google'], // REQUIRED to serve any OAuth route — the default is []
16
16
  link: 'verified-email', // the default; `'never'` is the only other value
17
17
  });
18
18
 
@@ -223,7 +223,7 @@ registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'ht
223
223
  | `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
224
224
  | `hasOAuthProvider(id)` | whether the id is registered |
225
225
  | `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal names |
226
- | `oauthProviderIds()` | every registered id, live `defineAuth({ providers })` defaults to it |
226
+ | `oauthProviderIds()` | every registered id, live. **NOT** what `defineAuth({ providers })` defaults to — that is `[]`, so an app names what it enabled |
227
227
 
228
228
  `discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
229
229
  to check an id token's signature against, and the token-endpoint TLS exemption below is a thing a
@@ -644,7 +644,7 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
644
644
  | `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
645
645
  | `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
646
646
  | `X_OAUTH_TOKEN_INVALID` | the id token failed its signature, issuer, audience or expiry check, or no key in the published set matched its `kid` |
647
- | `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered, or one `defineAuth({ providers })` did not enable — the route's refusal lists only the three built-ins, never your registry |
647
+ | `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered, one `defineAuth({ providers })` did not enable, or one whose `*_CLIENT_ID`/`*_CLIENT_SECRET` are unset all three answer 404 with the same body, because telling an anonymous caller which is which describes this deployment for free. The real reason is logged. The refusal lists only the three built-ins, never your registry |
648
648
  | `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
649
649
  | `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
650
650
  | `X_PASSWORD_WEAK` | strength check rejected the password |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "9.0.0",
3
+ "version": "10.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,8 +31,8 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "9.0.0",
35
- "@ultimat3/db": "9.0.0",
36
- "@ultimat3/schema": "9.0.0"
34
+ "@ultimat3/core": "10.0.0",
35
+ "@ultimat3/db": "10.0.0",
36
+ "@ultimat3/schema": "10.0.0"
37
37
  }
38
38
  }
package/src/auth.ts CHANGED
@@ -10,7 +10,6 @@ import { normaliseEmail } from './email';
10
10
  import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
11
11
  import { installedAuthLimiter } from './limiter-install';
12
12
  import type { OAuthProviderId } from './oauth';
13
- import { oauthProviderIds } from './oauth-registry';
14
13
  import {
15
14
  checkPasswordStrength,
16
15
  DEFAULT_PASSWORD_POLICY,
@@ -147,6 +146,11 @@ export interface AuthConfigInput {
147
146
  */
148
147
  readonly orgLimiter?: AuthLimiter | undefined;
149
148
  readonly mfa?: Partial<AuthMfaPolicy> | undefined;
149
+ /**
150
+ * The OAuth providers this app serves login routes for. Defaults to `[]` — an empty list is
151
+ * "no OAuth", and every `/auth/oauth/<id>` answers `X_OAUTH_PROVIDER_UNKNOWN`. Never the live
152
+ * registry, which a dependency can write into.
153
+ */
150
154
  readonly providers?: readonly OAuthProviderId[] | undefined;
151
155
  /** Defaults to `'verified-email'` — both halves proven. See `OAuthLinkPolicy`. */
152
156
  readonly link?: OAuthLinkPolicy | undefined;
@@ -213,7 +217,17 @@ export function defineAuth(config: AuthConfigInput): Auth {
213
217
  orgRateLimit: orgLimiter.policy,
214
218
  orgLimiter,
215
219
  mfa,
216
- providers: config.providers ?? oauthProviderIds(),
220
+ // BREAKING (majors only): the default is `[]`, never the live registry.
221
+ //
222
+ // It was `oauthProviderIds()`, so `defineAuth({ providers })`'s own documented purpose — the
223
+ // uniform 404 for a provider this app "left out" — could never fire: nothing was ever left
224
+ // out. Worse, a registry any dependency writes into with `registerOAuthProvider` decided which
225
+ // login endpoints an app serves. A capability is DECLARED, never inherited.
226
+ //
227
+ // Migration is one line: `defineAuth({ providers: ['github', 'google'] })`, naming what the app
228
+ // actually enabled. Nothing else changes — an unnamed provider is the 404 it was always meant
229
+ // to be.
230
+ providers: config.providers ?? [],
217
231
  link: config.link ?? 'verified-email',
218
232
  });
219
233
  }
@@ -286,10 +300,21 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
286
300
  }
287
301
 
288
302
  await auth.limiter.recordSuccess(account);
289
- if (ip !== null) await auth.limiter.recordSuccess(ipKey(ip));
290
- // No `recordSuccess` on the tenant bucket, and that asymmetry is the point: one member signing
291
- // in successfully must not clear the count a broken integration is running up beside them, or
292
- // the tenant cap is cleared by exactly the traffic that proves the tenant is still in use.
303
+ // ONLY the account bucket is cleared. The ACCOUNT window belongs to one person, so a success
304
+ // proves the typos before it were theirs and clearing it is what stops a typo costing a lockout.
305
+ //
306
+ // Neither the IP nor the tenant bucket is cleared, and it is the same argument for both: they
307
+ // count traffic from a SHARED source, so a success is not evidence the failures beside it were
308
+ // benign. `recordSuccess(ipKey(ip))` used to run here and deleted the whole address bucket —
309
+ // which made it inert against the attack it exists for. A credential-stuffing run never spends
310
+ // `maxAttempts` guesses on one account, so the per-account bucket never fires; the address
311
+ // bucket is the only one that sees the pattern, and `4 wrong guesses + 1 login to an account
312
+ // the attacker owns, repeat` wiped it every fifth request. Measured: 5 guesses to
313
+ // `X_ACCOUNT_LOCKED` without the reset, 160 and never locked with it.
314
+ //
315
+ // The cost is a shared NAT accumulating failures from unrelated people, which is exactly what
316
+ // `windowMs` bounds — and `X_ACCOUNT_LOCKED`'s `fix:` already names `recordSuccess(<key>)` as
317
+ // the deliberate manual escape for that case.
293
318
 
294
319
  // Parameters were raised since this hash was written: upgrade it now, while we hold the
295
320
  // plaintext. This is the only moment it is possible without asking the user for anything.
package/src/errors.ts CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  renderFixLiteral,
10
10
  UltimateError,
11
11
  } from '@ultimat3/core';
12
- import { oauthStartPath } from './oauth-paths';
13
12
 
14
13
  /** Codes this package declares and owns. `X_UNAUTHENTICATED` is auth's; http only borrows it. */
15
14
  export const AUTH_OWNED_ERROR_CODES = [
@@ -95,11 +94,12 @@ export class AuthError extends UltimateError {
95
94
  fix: string;
96
95
  meta?: Readonly<Record<string, unknown>> | undefined;
97
96
  }) {
97
+ // No `docs:`: `UltimateError` fills it from `describeErrorCode(code).docs`. The
98
+ // `https://ultimate.dev/errors/<code>` link this built until 9.x answered 404, host and all.
98
99
  super({
99
100
  code: init.code,
100
101
  cause: init.cause,
101
102
  fix: init.fix,
102
- docs: `https://ultimate.dev/errors/${init.code}`,
103
103
  meta: init.meta,
104
104
  });
105
105
  }
@@ -216,176 +216,6 @@ export const mfaRequiredUnenforceable = (): AuthError =>
216
216
  fix: 'drop required from defineAuth({ mfa }) and gate it in your own sign-in handler, which is where the enrolment route lives: if (user.mfaSecret === null) send them to enrolTotp(auth, { account: user.email }) instead of createSession(auth.sessions, ...)',
217
217
  });
218
218
 
219
- /**
220
- * The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
221
- * line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
222
- * so every caller who followed it hit a 404. One declaration, read by the mount and by the fix,
223
- * is what stops that recurring — `oauthLogin()` cannot move without moving this sentence.
224
- */
225
- export const oauthStateInvalid = (provider: string, part: string): AuthError =>
226
- new AuthError({
227
- code: 'X_OAUTH_STATE_INVALID',
228
- cause: `${provider} callback rejected: ${part}`,
229
- fix: `${restartAt(provider)} — a callback URL is single-use`,
230
- meta: { provider },
231
- });
232
-
233
- /** The one phrase every "start over" fix is built from, so none of them can name a dead route. */
234
- export const restartAt = (provider: string): string =>
235
- `restart the flow at GET ${oauthStartPath(provider)}`;
236
-
237
- /**
238
- * The provider came back with `error=` and no code — almost always the user pressing Cancel.
239
- * A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
240
- * misconfigured, and folding the single commonest non-success outcome of a login into the code
241
- * that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
242
- */
243
- export const oauthDenied = (
244
- provider: string,
245
- reason: string,
246
- description: string | null,
247
- ): AuthError =>
248
- new AuthError({
249
- code: 'X_OAUTH_DENIED',
250
- // `reason` and `description` are query parameters off the callback URL — whatever the browser
251
- // was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
252
- // string literals, so a forged `error_description` cannot forge a second log line or break the
253
- // sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
254
- cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
255
- description === null ? '' : ` (${renderCauseValue(description)})`
256
- }`,
257
- fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
258
- meta: { provider, reason },
259
- });
260
-
261
- /**
262
- * A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
263
- * was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
264
- * describes the app's configuration to an unauthenticated caller, and the fix is the same sentence
265
- * either way.
266
- *
267
- * **`supported` is the CALLER's to scope, because the two callers have two audiences.**
268
- * `oauth-route.ts` passes `BUILTIN_OAUTH_PROVIDER_IDS` — its reader is an anonymous stranger who
269
- * typed a URL, and the three built-ins are a framework constant already in the public docs, while
270
- * the live registry holds whatever internal OP this deployment registered. `providerFor()` passes
271
- * `oauthProviderIds()` — its reader is a developer holding a stack trace, and there the full list
272
- * is exactly what makes the fix runnable. Neither ever passes `defineAuth({ providers })`: naming
273
- * what this deployment turned on is the disclosure the shared refusal exists to prevent.
274
- *
275
- * The fix names `registerOAuthProvider` first so it stays executable for the branch the narrowed
276
- * list cannot cover — a segment nothing registered cannot be added to `providers` at all, so
277
- * "add it" alone was an instruction that could not be followed.
278
- *
279
- * The segment itself is a URL path the caller typed, so it goes through `renderCauseValue` in the
280
- * sentence and `renderFixLiteral` in the command — a fix has to parse after a hostile value lands
281
- * in it.
282
- */
283
- export const oauthProviderUnknown = (provider: string, supported: readonly string[]): AuthError =>
284
- new AuthError({
285
- code: 'X_OAUTH_PROVIDER_UNKNOWN',
286
- cause: `no oauth provider is mounted at ${renderCauseValue(oauthStartPath(provider))}`,
287
- fix: `registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) if it is not built in, then add that id to defineAuth({ providers: [...] }) — known here: ${supported.map((id) => `'${id}'`).join(', ')}`,
288
- meta: { provider },
289
- });
290
-
291
- /**
292
- * Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
293
- * module imported second decide where every login for that id goes — including which `issuers`
294
- * an id token may claim — so the second registration refuses at boot instead.
295
- */
296
- export const oauthProviderDuplicate = (provider: string): AuthError =>
297
- new AuthError({
298
- code: 'X_OAUTH_PROVIDER_DUPLICATE',
299
- cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
300
- fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
301
- meta: { provider },
302
- });
303
-
304
- export interface OAuthExchangeFailure {
305
- readonly provider: string;
306
- /**
307
- * Which leg of the server-to-server conversation failed. `discovery` and `jwks` are the two
308
- * boot/verification legs an enterprise OP adds: reading `/.well-known/openid-configuration`,
309
- * and reading the key set an id token's signature is checked against.
310
- */
311
- readonly stage: 'token' | 'userinfo' | 'discovery' | 'jwks';
312
- readonly detail: string;
313
- readonly status?: number | undefined;
314
- readonly fix: string;
315
- }
316
-
317
- /**
318
- * Deliberately specific, unlike every credential error above it. This one describes a
319
- * conversation between two servers — naming the stage, the provider and its own status
320
- * discloses nothing about any user, and is the difference between a fixable misconfiguration
321
- * and a shrug.
322
- */
323
- export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
324
- new AuthError({
325
- code: 'X_OAUTH_EXCHANGE_FAILED',
326
- cause:
327
- `${failure.provider} ${failure.stage} request failed` +
328
- `${failure.status === undefined ? '' : ` with HTTP ${failure.status}`}: ${failure.detail}`,
329
- fix: failure.fix,
330
- meta: {
331
- provider: failure.provider,
332
- stage: failure.stage,
333
- ...(failure.status === undefined ? {} : { status: failure.status }),
334
- },
335
- });
336
-
337
- /**
338
- * The address is proven to the provider, and an account that never proved it already holds it.
339
- * Naming that is not account enumeration — this caller just demonstrated they own the address —
340
- * and staying silent would leave them with a login that fails forever and no way out.
341
- *
342
- * The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
343
- * key, and cannot redact an address that was already interpolated into a sentence.
344
- */
345
- export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
346
- new AuthError({
347
- code: 'X_UNAUTHENTICATED',
348
- cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
349
- fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
350
- meta: { provider, email },
351
- });
352
-
353
- /**
354
- * `link: 'never'` and a local account already holds the address. Same code and same disclosure
355
- * rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
356
- * so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
357
- */
358
- export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
359
- new AuthError({
360
- code: 'X_UNAUTHENTICATED',
361
- cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
362
- fix: "sign in with that account's own credentials, or set link: 'verified-email' in defineAuth to let a provider-verified address claim a locally-verified account",
363
- meta: { provider, email },
364
- });
365
-
366
- /**
367
- * `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
368
- * write. Falling back to the unstamped row would mint a session for a user every later login
369
- * reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
370
- * flow fails closed on an adapter that loses the stamp instead of half-succeeding.
371
- */
372
- export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
373
- new AuthError({
374
- code: 'X_NOT_IMPLEMENTED',
375
- cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
376
- fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
377
- meta: { provider, userId },
378
- });
379
-
380
- /** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
381
- export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
382
- new AuthError({
383
- code: 'X_OAUTH_TOKEN_INVALID',
384
- cause: `${provider} id token rejected: ${reason}`,
385
- fix,
386
- meta: { provider },
387
- });
388
-
389
219
  export const passwordWeak = (reasons: readonly string[]): AuthError =>
390
220
  new AuthError({
391
221
  code: 'X_PASSWORD_WEAK',
@@ -411,6 +241,9 @@ export const accountLocked = (key: string, retryAfterSeconds: number): AuthError
411
241
  // escaping, not throw-safety.
412
242
  cause: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
413
243
  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 })`,
244
+ // `kdfOverloaded`'s shape: `@ultimat3/http`'s `retryAfterOf` reads exactly this field. `key`
245
+ // stays out — `cause`/`fix` escape it on purpose and `meta` is read by surfaces that do not.
246
+ meta: { retryAfterSeconds },
414
247
  });
415
248
 
416
249
  /** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
@@ -436,6 +269,25 @@ export const authWriteFailed = (operation: string, table: string): AuthError =>
436
269
  meta: { operation, table },
437
270
  });
438
271
 
272
+ /**
273
+ * A write the table's own UNIQUE constraint refuses. Same code as the write above, because it is
274
+ * the same failure to an operator — the row is not there and the caller must not assume it is —
275
+ * and the cause says which of the two happened.
276
+ *
277
+ * `MemoryAdapter` enforced neither `x_users.email` nor `x_users.external_id`, while `BuiltinAdapter`
278
+ * leans on both: two `register()` calls at one address made TWO rows in memory, and the second was
279
+ * unreachable forever because `findUserByEmail` returns the first. That adapter is what `x new`
280
+ * scaffolds and what every test runs against, so the duplicate path was only exercised against the
281
+ * permissive half of the seam.
282
+ */
283
+ export const authUniqueViolation = (operation: string, table: string, column: string): AuthError =>
284
+ new AuthError({
285
+ code: 'X_AUTH_WRITE_FAILED',
286
+ cause: `${operation} would add a second ${table} row with the same ${column}, which that column's unique constraint refuses`,
287
+ fix: `look the row up first and update it — findUserByEmail(normaliseEmail(email)) — or write a different ${column}`,
288
+ meta: { operation, table, column },
289
+ });
290
+
439
291
  /**
440
292
  * At `defineAuth`, never at a login. `replicas: 3` behind one policy means each process counts
441
293
  * failures on its own, so the account survives `maxAttempts × 3` guesses and a lockout established
package/src/id-token.ts CHANGED
@@ -7,10 +7,10 @@
7
7
 
8
8
  import type { Clock } from '@ultimat3/core';
9
9
  import { renderCauseValue } from '@ultimat3/core';
10
- import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './errors';
11
10
  import { decodeJwtSegment } from './json';
12
11
  import { type IdTokenKeys, verifyJwtSignature } from './jwks';
13
12
  import type { OAuthProvider, OAuthProviderId } from './oauth';
13
+ import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './oauth-errors';
14
14
  import { providerFor } from './oauth-registry';
15
15
  import { timingSafeEqual } from './tokens';
16
16
 
@@ -21,6 +21,13 @@ export interface IdTokenClaims {
21
21
  readonly sub: string;
22
22
  readonly exp: number;
23
23
  readonly iat?: number | undefined;
24
+ /** Not-before. Checked with the same skew `workload.ts` allows, from the same constant. */
25
+ readonly nbf?: number | undefined;
26
+ /**
27
+ * Authorised party — the client this token was minted FOR. OIDC Core 3.1.3.7 step 5: with more
28
+ * than one audience, `aud` naming this client is not evidence the token is addressed to it.
29
+ */
30
+ readonly azp?: string | undefined;
24
31
  readonly nonce?: string | undefined;
25
32
  readonly email?: string | undefined;
26
33
  /** Google sends a boolean, Apple a `"true"` string. Both mean the same thing. */
@@ -75,6 +82,8 @@ export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTok
75
82
  }
76
83
  // `exactOptionalPropertyTypes`: an absent claim must be absent, not present-and-undefined.
77
84
  const iat = payload['iat'];
85
+ const nbf = payload['nbf'];
86
+ const azp = stringOrUndefined(payload['azp']);
78
87
  const verified = payload['email_verified'];
79
88
  const nonce = stringOrUndefined(payload['nonce']);
80
89
  const email = stringOrUndefined(payload['email']);
@@ -85,6 +94,8 @@ export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTok
85
94
  sub,
86
95
  exp,
87
96
  ...(typeof iat === 'number' ? { iat } : {}),
97
+ ...(typeof nbf === 'number' ? { nbf } : {}),
98
+ ...(azp === undefined ? {} : { azp }),
88
99
  ...(nonce === undefined ? {} : { nonce }),
89
100
  ...(email === undefined ? {} : { email }),
90
101
  ...(typeof verified === 'boolean' || typeof verified === 'string'
@@ -167,7 +178,19 @@ export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenC
167
178
  );
168
179
  }
169
180
 
170
- if (claims.exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= input.clock.now().getTime()) {
181
+ // OIDC Core 3.1.3.7 steps 4-5. With more than one audience, `aud` naming this client says only
182
+ // that the token MENTIONS it — the authorised party is what says it was minted for it. Without
183
+ // this, a token an OP issued for another client that also lists ours verified here.
184
+ if (audience.length > 1 && claims.azp !== input.clientId) {
185
+ throw oauthTokenInvalid(
186
+ provider.id,
187
+ 'the token names several audiences and its azp is not this client',
188
+ `ask ${provider.id} for a token whose azp is ${provider.clientIdEnv}'s client id, or one with a single aud`,
189
+ );
190
+ }
191
+
192
+ const nowMs = input.clock.now().getTime();
193
+ if (claims.exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= nowMs) {
171
194
  throw oauthTokenInvalid(
172
195
  provider.id,
173
196
  'the token is already expired',
@@ -175,6 +198,17 @@ export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenC
175
198
  );
176
199
  }
177
200
 
201
+ // The bound `workload.ts` already checks, with the same skew, from THIS FILE's constant — it
202
+ // imports `ID_TOKEN_CLOCK_SKEW_MS` from here and then enforced a limit here did not. An `nbf`
203
+ // ten years out verified.
204
+ if (claims.nbf !== undefined && claims.nbf * 1000 - ID_TOKEN_CLOCK_SKEW_MS > nowMs) {
205
+ throw oauthTokenInvalid(
206
+ provider.id,
207
+ 'the token is not valid yet',
208
+ `sync this host's clock (\`timedatectl status\`), then ${restartAt(provider.id)}`,
209
+ );
210
+ }
211
+
178
212
  if (provider.usesNonce && !timingSafeEqual(input.nonce, claims.nonce ?? '')) {
179
213
  throw oauthStateInvalid(provider.id, 'the id token nonce did not match the stored handshake');
180
214
  }
package/src/index.ts CHANGED
@@ -57,7 +57,7 @@ export { describeUser, findUserByExternalId, listOrgUsers } from './directory';
57
57
  // The one normalisation an address gets before it is an identity key. Public because an app
58
58
  // writing its own `AuthAdapter`, or its own login route, has to key exactly the way this does.
59
59
  export { normaliseEmail } from './email';
60
- export type { AuthErrorCode, AuthThrowCode, OAuthExchangeFailure } from './errors';
60
+ export type { AuthErrorCode, AuthThrowCode } from './errors';
61
61
  export {
62
62
  AUTH_BORROWED_ERROR_CODES,
63
63
  AUTH_ERROR_CODES,
@@ -68,28 +68,18 @@ export {
68
68
  authLimiterNotShared,
69
69
  authLimiterPolicyMismatch,
70
70
  authNotImplemented,
71
+ authUniqueViolation,
71
72
  authWriteFailed,
72
- emailVerifiedNotStored,
73
73
  forbidden,
74
74
  kdfOverloaded,
75
75
  mfaRequired,
76
76
  mfaRequiredUnenforceable,
77
77
  mfaSecretInvalid,
78
- oauthAccountNotLinked,
79
- oauthDenied,
80
- oauthExchangeFailed,
81
- oauthLinkingDisabled,
82
- oauthProviderDuplicate,
83
- oauthProviderUnknown,
84
- oauthStateInvalid,
85
- oauthTokenInvalid,
86
78
  passwordWeak,
87
- restartAt,
88
79
  sessionExpired,
89
80
  sessionUnknown,
90
81
  unauthenticated,
91
82
  } from './errors';
92
-
93
83
  export { currentActor, requireActor } from './guards';
94
84
  export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
95
85
  export {
@@ -180,6 +170,21 @@ export {
180
170
  } from './oauth-cookie';
181
171
  export type { DiscoverOAuthProviderInput } from './oauth-discovery';
182
172
  export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
173
+ // The OAuth half of the same contract, split out of `errors.ts` at the 500-line ceiling. Every
174
+ // name below was exported from `./errors` before the split and is exported here after it.
175
+ export type { OAuthExchangeFailure } from './oauth-errors';
176
+ export {
177
+ emailVerifiedNotStored,
178
+ oauthAccountNotLinked,
179
+ oauthDenied,
180
+ oauthExchangeFailed,
181
+ oauthLinkingDisabled,
182
+ oauthProviderDuplicate,
183
+ oauthProviderUnknown,
184
+ oauthStateInvalid,
185
+ oauthTokenInvalid,
186
+ restartAt,
187
+ } from './oauth-errors';
183
188
  export type {
184
189
  OAuthClientCredentials,
185
190
  OAuthExchangeOptions,
package/src/jwks.ts CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  import type { Clock } from '@ultimat3/core';
10
10
  import { renderThrowable, systemClock } from '@ultimat3/core';
11
- import { oauthExchangeFailed, oauthTokenInvalid } from './errors';
12
11
  import { decodeJwtSegment, isRecord } from './json';
13
12
  import type { OAuthProvider } from './oauth';
13
+ import { oauthExchangeFailed, oauthTokenInvalid } from './oauth-errors';
14
14
  import type { OAuthFetch } from './oauth-exchange';
15
15
  import { base64UrlBytes } from './tokens';
16
16
 
@@ -200,8 +200,19 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
200
200
  const clients = new Map<string, JwksKeySource>();
201
201
 
202
202
  /**
203
- * The provider's own key set, built once per provider id. Memoised because the cache is the point:
204
- * a client rebuilt per request refetches the key set per request.
203
+ * The provider's own key set. The DEFAULT client is built once per provider id memoised because
204
+ * the cache is the point: a client rebuilt per request refetches the key set per request.
205
+ *
206
+ * A caller that SUPPLIES options gets a client built with them, and is not served the memo. The
207
+ * memo was keyed on the provider id alone, so the second caller's `fetch`, `clock`, `ttlMs` and
208
+ * `timeoutMs` were silently discarded: an app pinning a corporate egress proxy got it only if it
209
+ * happened to call first, and nothing said otherwise. That is the `jobs.driver` shape — a key read
210
+ * once and thereafter ignored — except the value quietly substituted here is a network path.
211
+ *
212
+ * Not cached by option identity, deliberately: `fetch` and `clock` are functions and objects, so
213
+ * any canonical key over them either collides (two different proxies, one entry) or never hits.
214
+ * A bespoke client is a bespoke client; `createJwksClient` is what it is, and its own cache still
215
+ * works for as long as the caller holds it.
205
216
  */
206
217
  export function providerJwks(
207
218
  provider: OAuthProvider,
@@ -214,10 +225,11 @@ export function providerJwks(
214
225
  `register ${provider.id} with an explicit jwksUri, or read its id token only through exchangeOAuthCode()`,
215
226
  );
216
227
  }
217
- const existing = clients.get(provider.id);
228
+ const bespoke = options !== undefined && Object.keys(options).length > 0;
229
+ const existing = bespoke ? undefined : clients.get(provider.id);
218
230
  if (existing !== undefined) return existing;
219
231
  const client = createJwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
220
- clients.set(provider.id, client);
232
+ if (!bespoke) clients.set(provider.id, client);
221
233
  return client;
222
234
  }
223
235
 
@@ -14,6 +14,7 @@ import type {
14
14
  UserPatch,
15
15
  UserQuery,
16
16
  } from './adapter';
17
+ import { authUniqueViolation } from './errors';
17
18
  import { timingSafeEqual } from './tokens';
18
19
 
19
20
  const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
@@ -43,7 +44,25 @@ export class MemoryAdapter implements AuthAdapter {
43
44
  return this.#users.get(id) ?? null;
44
45
  }
45
46
 
47
+ /**
48
+ * The two UNIQUE constraints `x_users` declares, enforced here because `BuiltinAdapter` LEANS on
49
+ * them: `email text not null unique` and `external_id text unique` (`tables.ts`). Without them
50
+ * this adapter — the one `x new` scaffolds and every test runs against — accepted two rows at one
51
+ * address, and the second was unreachable forever, since `findUserByEmail` returns the first.
52
+ *
53
+ * Over the STORED string, exactly as Postgres compares it. No case folding: that is the
54
+ * divergence `adapter-parity.test.ts`'s first case pins, and `normaliseEmail` above the seam is
55
+ * what makes two spellings one address.
56
+ */
46
57
  async createUser(input: CreateUserInput): Promise<AuthUser> {
58
+ for (const existing of this.#users.values()) {
59
+ if (existing.email === input.email) {
60
+ throw authUniqueViolation('createUser', 'x_users', 'email');
61
+ }
62
+ if (input.externalId !== undefined && existing.externalId === input.externalId) {
63
+ throw authUniqueViolation('createUser', 'x_users', 'external_id');
64
+ }
65
+ }
47
66
  const user: AuthUser = {
48
67
  id: input.id,
49
68
  // Stored as handed over, exactly as the `insert into x_users` binds it.