@ultimat3/auth 24.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -12,7 +12,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
12
12
  | Better Auth | binds through `AuthAdapter`. It is an adapter, never a dependency. |
13
13
  | Errors | `AuthError` from `errors.ts`; never `throw new Error` |
14
14
  | Time | take a `Clock`. No `Date.now()` anywhere in this package. |
15
- | Secrets | compare with `timingSafeEqual` (from `@ultimat3/core`, re-exported off `tokens.ts` — same implementation `@ultimat3/storage` uses); store `sha256Hex`. Never `===` on a secret. |
15
+ | Secrets | compare with `timingSafeEqual` (imported from `@ultimat3/core` — never re-exported here, same implementation `@ultimat3/storage` uses); store `sha256Hex`. Never `===` on a secret. |
16
16
 
17
17
  ## Non-negotiables
18
18
 
@@ -63,7 +63,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
63
63
  the pool is the host's.
64
64
  - **`configureAuthLimiters` is the HOST's install point and takes a FACTORY**, called with the
65
65
  RESOLVED policy once per bucket; the comparison still runs on what comes back. Precedence:
66
- `config.limiter` → the installed factory → `createAuthLimiter`. `installedAuthLimiter` and
66
+ `config.limiter` → the installed factory → `memoryAuthLimiter`. `installedAuthLimiter` and
67
67
  `installedLimiterCount()` are NOT in `src/index.ts`. `purgeAuthLimits()` sweeps only the
68
68
  **widest** window among the built limiters (retained one per `windowMs` in a `Map`), on the
69
69
  limiter's own clock. `AuthLimiter.purgeExpired` is optional.
@@ -86,6 +86,9 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
86
86
  `alg: none` are refused in `decodeJwtHeader`. It checks `nbf` and `azp` too.
87
87
  - **`resolveGrants` is a seam, never a group-to-role table**, called on EVERY login. Absent leaves
88
88
  the row alone. A user created with no roles and no org logs a warning.
89
+ Its second argument (`OAuthGrantContext`, `oauth-grant-context.ts`, #670) is the token set, the
90
+ whole verified id-token payload and a LAZY, once-per-login, `sub`-checked `userinfo()` — read
91
+ through `oauth-profile.ts`'s `userinfoBody`, never a second userinfo reader.
89
92
  - **`verifySession` writes at most once per `idleSlideMs`** (default `idleTtlMs / 20`). Never cache
90
93
  the USER row — `authenticate` re-reads it every request so a revoked role bites on the next one.
91
94
  In `observed`, `null` is an ANSWER (clears the stored value) and `undefined` is silence.
@@ -112,6 +115,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
112
115
  `FIX_SHELL_ARG_PINS`). `${provider}` is registry-validated boot config, not foreign.
113
116
  - Cookies are read through `@ultimat3/core`'s `readCookie` (`readSessionCookie`, the oauth
114
117
  handshake), which never throws on a malformed value (`decodeURIComponent('%')`).
118
+ They are WRITTEN through its `serializeSetCookie`, never a literal (`bun run set-cookie-literals`,
119
+ `X_SET_COOKIE_HAND_BUILT`); `cookie-golden.test.ts` pins the bytes.
115
120
  - A token endpoint's HTTP 200 is not success — read `error`.
116
121
  - Link by address only when the provider **and** the local account both verified it
117
122
  (`link: 'verified-email'`, default; `'never'` is the only other value).
@@ -140,18 +145,20 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
140
145
  changed underneath is `skipped` — and `x doctor` counts what is left. Whether a value is sealed is
141
146
  `isSealed`'s to say, never a `like` in an adapter (`listUsersWithMfaSecret` returns them all).
142
147
  - **`providerJwks` memoises only the DEFAULT client**; a caller supplying options gets its own.
143
- - **A JWKS refresh is single-flighted through core's `createSingleFlight` with
144
- `deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `createFence`
148
+ - **A JWKS refresh is single-flighted through core's `singleFlight` with
149
+ `deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `generationFence`
145
150
  generation check (read, never `guard`) stops a superseded refresh overwriting the cache.
146
151
  `schedule` is injectable.
147
152
  - **A success clears the ACCOUNT bucket and nothing else** — clearing the address bucket made the
148
153
  limiter inert against stuffing. A password proven with a second factor still owed clears
149
154
  NOTHING: `completeMfa` is what clears, or a repeat of the first leg wipes the wrong codes.
150
- - **`MemoryAdapter.createUser` and `updateUser` enforce `x_users.id`, `email` and `external_id`
155
+ - **`MemoryAuthAdapter.createUser` and `updateUser` enforce `x_users.id`, `email` and `external_id`
151
156
  uniqueness** (`authUniqueViolation`, `X_AUTH_WRITE_FAILED`), NULLS DISTINCT like Postgres;
152
- `BuiltinAdapter` answers the same code through `builtin-adapter-violation.ts`.
153
- **`MemoryAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
157
+ `PostgresAuthAdapter` answers the same code through `builtin-adapter-violation.ts`.
158
+ **`MemoryAuthAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
154
159
  `adapter-parity.test.ts` pins both.
160
+ - **`listUsersByOrg` orders by byte**: `collate "C"` in Postgres, a code-point compare in memory —
161
+ never `localeCompare`, never UTF-16 `<` (`list-users-order.live.test.ts`, on an `en_US` db).
155
162
  - Every `AuthAdapter` member is REQUIRED — a missing one is a compile error at `defineAuth`, and
156
163
  no caller in this package refuses at runtime with `X_NOT_IMPLEMENTED`.
157
164
  - **An api key is its owner's credential.** `verifyApiKey` re-reads the owner (after the secret
@@ -167,12 +174,15 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
167
174
  `verifyApiKey` → `apiKeyActor`, `X_API_KEY_INVALID` → `null`, everything else rethrown. Its
168
175
  `ApiKeyCaller` is structurally http's `BearerCaller` and mcp's `ResolvedToken` — declared here
169
176
  because tier 2 imports neither. The store is a thunk (a mount is declared before boot installs
170
- the database client). No second in-memory key store: `MemoryAdapter` IS an `ApiKeyStore`.
177
+ the database client). No second in-memory key store: `MemoryAuthAdapter` IS an `ApiKeyStore`.
171
178
  - Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
172
179
  `privileges.ts`), never patch the row. A `passwordHash` change also ends every OTHER session of
173
180
  that user (`deleteOtherSessions`), or all of them when the caller passed no session of theirs.
181
+ **A new id, never a new lifetime**: rotation carries `createdAt` and `absoluteExpiresAt` over
182
+ (`createSession` clamps both, so they only shorten), and the rotated cookie's `Max-Age` is
183
+ `remainingMaxAgeSeconds` — `session-rotation.test.ts` pins both adapters.
174
184
  - **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
175
- (borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `createFlightGate`, the
185
+ (borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `flightGate`, the
176
186
  refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
177
187
  and deliberately not a `defineAuth` key.
178
188
  - **MFA's second leg is `completeMfa(auth, challenge, code)`** (`mfa-challenge.ts`). `login()`
@@ -191,7 +201,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
191
201
  - **A TOTP secret that decodes to zero bytes is NO key**: `verifyTotp` returns the generic failure
192
202
  (`{ ok: false, step: null }`), `totpCode` and `enrolTotp` throw `X_MFA_SECRET_INVALID`. The secret
193
203
  never reaches `cause:`/`fix:`. No length floor beyond one byte.
194
- - **`createTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
204
+ - **`memoryTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
195
205
  whose steps are all below the drift floor are forgotten first; past `DEFAULT_MAX_TOTP_SUBJECTS`,
196
206
  evict by newest spent step ascending, least-recently-seen tie-break. `maxSubjects` is normalised
197
207
  first (`boundedSubjects`): anything not a positive finite integer takes the default.
@@ -227,14 +237,15 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
227
237
  | `id-token.ts` | id token → claims this handshake may believe |
228
238
  | `id-token-fixture.ts` | the one string-input JWT builder the OAuth tests share. Off `index.ts` |
229
239
  | `auth-fixture.ts` | the KDF parameters, the password and the `AuthError` catcher the three `auth*.test.ts` suites share. Off `index.ts` |
230
- | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile` |
240
+ | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile`; `userinfoBody`, the one userinfo reader |
241
+ | `oauth-grant-context.ts` | `resolveGrants`' second argument: tokens, every id-token claim, a lazy subject-checked `userinfo()` |
231
242
  | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
232
243
  | `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
233
244
  | `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
234
245
  | `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
235
246
  | `oauth-errors.ts` | the OAuth refusals more than one OAuth module raises, and `restartAt`. A refusal with one thrower lives beside it; declares no code and registers nothing |
236
247
  | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
237
- | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `createFlightGate` with auth's own refusal injected |
248
+ | `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it — core's `flightGate` with auth's own refusal injected |
238
249
  | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
239
250
  | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
240
251
  | `sign-out.ts` | `signOutHeaders({ session? })` — the expired session cookie plus `Clear-Site-Data: "cache", "storage"` (`SIGN_OUT_CLEAR_SITE_DATA`), never `"cookies"`. A PWA's offline cache does not survive a sign-out, by decision (a cached private page IS the previous principal's data); the page boot's rescope wipe is the second line. Secure contexts only |
@@ -252,7 +263,7 @@ Gotchas:
252
263
  this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
253
264
  without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
254
265
  supposed to fire when two packages claim one code.
255
- - Tests run against `MemoryAdapter`; the one ADAPTER test that boots a database is
266
+ - Tests run against `MemoryAuthAdapter`; the one ADAPTER test that boots a database is
256
267
  `adapter-parity-identity.test.ts` (PGlite). The two other exceptions are
257
268
  `postgresAuthLimiter`'s, and they are exceptions in the shape the repo already has: the
258
269
  scripted-executor twin (`rate-limit-postgres.test.ts`) proves the protocol with no server,
package/README.md CHANGED
@@ -5,10 +5,10 @@ authorizes on a session row, a user row or an api key — http, actions, jobs an
5
5
  `ctx.actor` and hand it to `@ultimat3/policy`. One authz system, never two.
6
6
 
7
7
  ```ts
8
- import { BuiltinAdapter, defineAuth, login, oauthLogin } from '@ultimat3/auth';
8
+ import { defineAuth, login, oauthLogin, postgresAuthAdapter } from '@ultimat3/auth';
9
9
 
10
10
  export const auth = defineAuth({
11
- adapter: new BuiltinAdapter(), // or MemoryAdapter, or your Better Auth binding
11
+ adapter: postgresAuthAdapter(), // or memoryAuthAdapter(), or your Better Auth binding
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`
@@ -46,7 +46,7 @@ const { start, callback } = oauthLogin(auth);
46
46
 
47
47
  ## The lockout across replicas
48
48
 
49
- `createAuthLimiter` keeps its table in the process, so `maxAttempts: 5` at `replicas: 3` lets an
49
+ `memoryAuthLimiter` keeps its table in the process, so `maxAttempts: 5` at `replicas: 3` lets an
50
50
  account survive 15 guesses and hides each replica's lockout from the other two. An app that runs
51
51
  more than one process says so and brings a limiter that says the same:
52
52
 
@@ -96,7 +96,7 @@ const { identity } = await verifyWorkloadToken({
96
96
  token,
97
97
  issuers: ['https://kubernetes.default.svc'],
98
98
  audience: 'https://ledger.internal',
99
- keys: createJwksClient({ provider: 'k8s', jwksUri: 'https://kubernetes.default.svc/openid/v1/jwks' }),
99
+ keys: jwksClient({ provider: 'k8s', jwksUri: 'https://kubernetes.default.svc/openid/v1/jwks' }),
100
100
  clock,
101
101
  });
102
102
  const actor = actorFromService(identity); // kind: 'service', id: the caller's own sub
@@ -193,7 +193,7 @@ A **factory** and not a limiter, because the host runs before the app: `defineAu
193
193
  limiter enforces against what the app declared, so a limiter built at boot on the framework
194
194
  defaults would be `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned its numbers. The
195
195
  factory is called once per bucket, with the resolved policy, so the two halves cannot disagree.
196
- Precedence is `defineAuth({ limiter })` → the installed factory → `createAuthLimiter`, and
196
+ Precedence is `defineAuth({ limiter })` → the installed factory → `memoryAuthLimiter`, and
197
197
  `resetAuthLimiters()` puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
198
198
  on every boot, so a scaffolded app gets a fleet-wide lockout with nothing to remember.
199
199
 
@@ -263,7 +263,7 @@ because a default is what silently makes a second door as trusting as the first.
263
263
  | `keys` | Means |
264
264
  |---|---|
265
265
  | `'token-endpoint-tls'` | this token came off a TLS response from the provider's own token endpoint — the one case OIDC Core 3.1.3.7 exempts. `exchangeOAuthCode` passes it, and that is the only shipped call site that may |
266
- | a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `createJwksClient({ provider, jwksUri })` |
266
+ | a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `jwksClient({ provider, jwksUri })` |
267
267
 
268
268
  ```ts
269
269
  const keys = providerJwks(providerFor('bigco-sso'));
@@ -302,6 +302,31 @@ convention and business convention never ships ([axiom
302
302
  **every** login — so removing somebody from a group in the IdP takes effect at their next sign-in,
303
303
  rather than never. A seam that returns the stored answer writes nothing.
304
304
 
305
+ **The profile is not all the IdP said.** `resolveGrants(profile, context)` takes a second argument
306
+ (`OAuthGrantContext`) for an IdP that keeps authorization facts off the id token — groups,
307
+ entitlements, an `allowed_oauth_apps` claim served on `/userinfo` only:
308
+
309
+ ```ts
310
+ import { defineAuth, memoryAuthAdapter, oauthLogin } from '@ultimat3/auth';
311
+
312
+ const auth = defineAuth({ adapter: memoryAuthAdapter(), providers: ['google'] });
313
+
314
+ export const { start, callback } = oauthLogin(auth, {
315
+ resolveGrants: async (_profile, context) => {
316
+ const claims = await context.userinfo();
317
+ const apps = Array.isArray(claims['allowed_oauth_apps']) ? claims['allowed_oauth_apps'] : [];
318
+ return apps.includes('bank-integrations') ? { roles: ['member'] } : { roles: [] };
319
+ },
320
+ });
321
+ ```
322
+
323
+ | `context.` | What it is |
324
+ |---|---|
325
+ | `provider` | the provider id this login came through |
326
+ | `tokens` | the exchange's `OAuthTokens`, in flight — the framework stores none of them; keeping one is the app storing a credential |
327
+ | `idTokenClaims` | every claim of the VERIFIED id token, the ones `OAuthProfile` drops included (`hd`, `groups`); `null` without one |
328
+ | `userinfo()` | the provider's userinfo body, every claim, read with the access token — lazily (no call, no request) and at most once per login; none at all when the profile was already read off userinfo (GitHub), whose body it hands back. A body naming another `sub` is `X_OAUTH_EXCHANGE_FAILED`; so is a provider with no userinfo endpoint, and a failed read, which is not remembered |
329
+
305
330
  ## MFA — TOTP and recovery codes
306
331
 
307
332
  TOTP is pure functions over a secret and a clock; what is STORED goes through two writers and one
@@ -339,7 +364,7 @@ every enrolled user's second factor.
339
364
  |---|---|
340
365
  | `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
341
366
  | `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
342
- | `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
367
+ | `memoryTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
343
368
  | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
344
369
  | `recoveryCodeHash(code)` | what a code is stored and looked up as — dashes, spaces and case removed first. `auth.adapter.consumeRecoveryCode(userId, hash)` removes it in ONE statement and answers whether it was there, so two requests carrying one code cannot both redeem it |
345
370
  | `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code. `totpCode` throws `X_MFA_SECRET_INVALID` on a secret that decodes to zero bytes |
@@ -438,14 +463,14 @@ import {
438
463
  consumeVerification,
439
464
  issueVerification,
440
465
  type MailSender,
441
- MemoryAdapter,
466
+ memoryAuthAdapter,
442
467
  type VerificationRuntime,
443
468
  } from '@ultimat3/auth';
444
469
  import { systemClock } from '@ultimat3/core';
445
470
 
446
471
  declare const mail: MailSender; // the app wires @ultimat3/mail's `send` here
447
472
 
448
- const runtime: VerificationRuntime = { store: new MemoryAdapter(), clock: systemClock, mail };
473
+ const runtime: VerificationRuntime = { store: memoryAuthAdapter(), clock: systemClock, mail };
449
474
 
450
475
  const issued = await issueVerification(runtime, {
451
476
  purpose: 'password-reset',
@@ -484,9 +509,15 @@ unauthenticated POST with any token cannot kill the victim's live link.
484
509
  | `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
485
510
  | `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
486
511
  | `disableUser(auth, userId, reason)` | stamps `disabledAt`, kills the sessions **and** revokes the user's live api keys — `{ user, sessionsRevoked, apiKeysRevoked }`. Re-enabling restores neither |
487
- | `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries |
512
+ | `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries — in byte (code-point) order of `email` on both adapters, whatever the database collation |
488
513
  | `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires. A `passwordHash` change ends every OTHER session of that user (`sessionsRevoked`) — all of them when no session of theirs was passed, as in a reset |
489
514
 
515
+ Rotation (`rotateSession`, and `updatePrivileges` through it) mints a new **id**, never a new
516
+ lifetime: `createdAt` and `absoluteExpiresAt` carry over, so repeated grants cannot outlive the
517
+ original ceiling and `revokeSessionsCreatedBefore` still catches a rotated session. The rotated
518
+ cookie's `Max-Age` counts down to that ceiling. `createSession` accepts the same two instants and
519
+ clamps them — `createdAt` to now, the ceiling to `createdAt + absoluteTtlMs` — so they only shorten.
520
+
490
521
  `reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
491
522
  an incident review asks who killed these sessions and why, and a `delete` with no line answers
492
523
  neither. Each one logs `auth.revocation` before it runs.
@@ -505,8 +536,8 @@ an adapter implementation, not a dependency of this package.
505
536
 
506
537
  | Driver | Use |
507
538
  |---|---|
508
- | `BuiltinAdapter` | Postgres via `@ultimat3/db`. `new BuiltinAdapter(client?, clock?)` — the process client and `systemClock` by default; the clock stamps `x_verifications.consumed_at` |
509
- | `MemoryAdapter` | `x new` before a database exists, and every test in this package. `new MemoryAdapter(clock)` — `systemClock` by default — stamps every instant it writes |
539
+ | `postgresAuthAdapter(client?, clock?)` | Postgres via `@ultimat3/db` (the `PostgresAuthAdapter` type) — the process client and `systemClock` by default; the clock stamps `x_verifications.consumed_at` |
540
+ | `memoryAuthAdapter(clock?)` | `x new` before a database exists, and every test in this package (the `MemoryAuthAdapter` type). Its clock — `systemClock` by default — stamps every instant it writes |
510
541
  | your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
511
542
 
512
543
  **An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
@@ -549,7 +580,10 @@ row would not be, and nothing does.
549
580
 
550
581
  ## Cookie
551
582
 
552
- `__Host-x_session`, set by `sessionCookie(token, policy)`.
583
+ `__Host-x_session`, set by `sessionCookie(token, policy)`. A **rotated** session keeps its
584
+ original ceiling, so its cookie takes the time left, not the full TTL:
585
+ `sessionCookie(issued.token, policy, { maxAgeSeconds: remainingMaxAgeSeconds(issued.session, clock.now()) })`
586
+ — what `updatePrivileges` already does for you.
553
587
 
554
588
  | Attribute | Attack it closes |
555
589
  |---|---|
@@ -559,6 +593,13 @@ row would not be, and nothing does.
559
593
  | `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
560
594
  | `Max-Age` | a client keeping it past the server's absolute ceiling |
561
595
 
596
+ Every cookie this package writes — the session, its clearing line, the OAuth handshake and its
597
+ clearing line — is built by `@ultimat3/core`'s `serializeSetCookie`, never spelled by hand
598
+ (`bun run set-cookie-literals`). `As of 2026-10-06` the attributes are written `Max-Age=…; Path=/`
599
+ rather than `Path=/; Max-Age=…` — the same cookie to a browser, which gives attribute order no
600
+ meaning — and a cookie `name` that is not an RFC 6265 token is `X_COOKIE_INVALID` instead of a
601
+ line a browser would mangle.
602
+
562
603
  ## Signing out
563
604
 
564
605
  `logout(auth, token)` ends the session row. The **response** does the rest: append
@@ -595,17 +636,39 @@ the previous scope's persisted rows and queued writes).
595
636
  `beginOAuth`, the handshake cookie and `completeOAuthLogin`. Provider configs stay pure data —
596
637
  importing `oauth.ts` performs no network I/O and reads no env.
597
638
 
639
+ Mount both in the app's own server through **`runtime.routes`**: `apps/<app>/runtime.ts`
640
+ exporting `runtime` (`RuntimeOverrides`). `x dev` and `runRole` both read that file and mount its
641
+ routes through the whole pipeline — `configureAuthenticator`, the rate limiter, the security
642
+ headers, the SIGTERM drain. Never a second `Bun.serve` beside the app: it stands on its own
643
+ socket, outside every one of those, and a login flow is the last surface that should run
644
+ unthrottled and unheadered.
645
+
598
646
  ```ts
647
+ // apps/web/runtime.ts
648
+ import {
649
+ type AuthRouteDescriptor,
650
+ defineAuth,
651
+ oauthLogin,
652
+ postgresAuthAdapter,
653
+ } from '@ultimat3/auth';
654
+ import type { ServeOptions } from '@ultimat3/cli/serve';
655
+ import type { Route } from '@ultimat3/http';
656
+
657
+ // The app's one `auth` — imported from wherever it declares it.
658
+ const auth = defineAuth({ adapter: postgresAuthAdapter(), providers: ['github'] });
599
659
  const { start, callback } = oauthLogin(auth);
600
660
 
601
- Bun.serve({
602
- fetch(request) {
603
- const { pathname } = new URL(request.url);
604
- if (pathname.endsWith('/callback')) return callback.handle(request);
605
- if (pathname.startsWith('/auth/oauth/')) return start.handle(request);
606
- return new Response(null, { status: 404 });
607
- },
661
+ // A descriptor takes a bare `Request`: the pipeline's `UltimateRequest` carries it as `raw`.
662
+ const mounted = (leg: AuthRouteDescriptor): Route => ({
663
+ method: leg.method,
664
+ path: leg.path,
665
+ handler: (request) => leg.handle(request.raw),
666
+ meta: { name: leg.name, auth: leg.auth },
608
667
  });
668
+
669
+ export const runtime: NonNullable<ServeOptions['runtime']> = {
670
+ routes: [start, callback].map(mounted),
671
+ };
609
672
  ```
610
673
 
611
674
  | | `start` | `callback` |
@@ -617,20 +680,9 @@ Bun.serve({
617
680
  A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
618
681
  is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
619
682
  a rendered page. A bare `Request` in, a `Response` out: drivable from a test, mountable by any
620
- router that can match a `:param`.
621
-
622
- **The `Bun.serve` above is library usage, not app usage.** An Ultimate app's server is `runRole`
623
- (`apps/web/server.ts` is three lines that call it), and `As of 2026-08` `ServeOptions` has no
624
- routes seam — the route list is built inside `serveApp` and closed. So a second `Bun.serve` in an
625
- app does not extend that server, it stands beside it: on its own socket, outside the pipeline, and
626
- therefore outside `configureAuthenticator`, the rate limiter, the security headers and the
627
- SIGTERM drain. A login flow is the last surface that should be the one running unthrottled and
628
- unheadered.
629
-
630
- Until the seam exists, an app serving these descriptors serves them itself and pays for all of
631
- that itself — a second port to publish and health-check, its own throttle in front of `callback`,
632
- its own security headers, and a drain that does not strand a handshake mid-flight. There is no
633
- mounting API to call today; do not write one, and do not read this section as promising one.
683
+ router that can match a `:param` — and `runtime.routes` is the one an app has. It mounts after the
684
+ framework's own routes and before the pages; every field it shares the file with is in
685
+ [`wiki/Configuration.md`](../../wiki/Configuration.md#runtime-overrides--appsappruntimets).
634
686
 
635
687
  **The paths are not configurable.** `X_OAUTH_STATE_INVALID` has always told the caller to restart
636
688
  at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
@@ -712,6 +764,9 @@ JWT signed with the `.p8` key, which Apple expires every six months.
712
764
  the status alone there mints a session from a failed exchange.
713
765
  - An address is only linked to an existing account when **both** sides verified it (`link:
714
766
  'verified-email'`). Otherwise whoever registered the address first inherits the login.
767
+ - An address the provider did **not** verify never creates an account either — it is the same
768
+ `loginFailed()` the taken-address case answers. Minting one is a pre-hijack: the owner's
769
+ `register()` then collides and their verified login is refused, with the attacker still linked.
715
770
 
716
771
  ## API keys — how an agent authenticates
717
772
 
@@ -766,13 +821,13 @@ for both doors.
766
821
 
767
822
  ```ts
768
823
  import type { ApiKeyVerifyStore } from '@ultimat3/auth';
769
- import { apiKeyResolver, BuiltinAdapter } from '@ultimat3/auth';
824
+ import { apiKeyResolver, postgresAuthAdapter } from '@ultimat3/auth';
770
825
 
771
826
  let keys: ApiKeyVerifyStore | undefined;
772
827
 
773
828
  /** `resolveToken: resolveKey` — on the mount, and on `defineAppMcp()`. */
774
829
  export const resolveKey = apiKeyResolver(() => {
775
- keys ??= new BuiltinAdapter();
830
+ keys ??= postgresAuthAdapter();
776
831
  return keys;
777
832
  });
778
833
  ```
@@ -784,8 +839,8 @@ export const resolveKey = apiKeyResolver(() => {
784
839
  | a store that FAILS | the throw, untouched — a database that is down is not a wrong key |
785
840
 
786
841
  The store is a THUNK, read when a token is presented: a mount is declared when its module is
787
- evaluated, and `new BuiltinAdapter()` takes the process's database client, which boot installs
788
- later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is an
842
+ evaluated, and `postgresAuthAdapter()` takes the process's database client, which boot installs
843
+ later. In a test the store is `memoryAuthAdapter()` — every `AuthAdapter` is an
789
844
  `ApiKeyVerifyStore` (an `ApiKeyStore` that can also `findUserById`).
790
845
 
791
846
  ## Errors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "24.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,15 +25,15 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.0"
28
+ "bun": ">=1.4.2"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "24.0.0",
36
- "@ultimat3/db": "24.0.0",
37
- "@ultimat3/schema": "24.0.0"
35
+ "@ultimat3/core": "25.0.0",
36
+ "@ultimat3/db": "25.0.0",
37
+ "@ultimat3/schema": "25.0.0"
38
38
  }
39
39
  }
package/src/adapter.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: the persistence seam. `AuthAdapter` is the one interface auth talks
2
2
  // to, split into per-concern stores so a test (or a caller) can satisfy just the slice it uses.
3
3
  // Better Auth binds here — it is an adapter implementation, not a dependency. The blessed
4
- // default is `BuiltinAdapter` in `builtin-adapter.ts`; the DDL it expects is in `tables.ts`.
4
+ // default is `PostgresAuthAdapter` in `builtin-adapter.ts`; the DDL it expects is in `tables.ts`.
5
5
 
6
6
  export interface AuthUser {
7
7
  readonly id: string;
@@ -137,6 +137,10 @@ export interface SessionStore {
137
137
  getSession(id: string): Promise<AuthSession | null>;
138
138
  createSession(session: AuthSession): Promise<AuthSession>;
139
139
  updateSession(id: string, patch: SessionPatch): Promise<AuthSession | null>;
140
+ /**
141
+ * Remove one session and answer whether THIS call removed it, in one atomic step: `rotateSession`
142
+ * treats `true` as its claim on the id, so two concurrent deletes must not both answer `true`.
143
+ */
140
144
  deleteSession(id: string): Promise<boolean>;
141
145
  /** Returns how many were killed — the "sign out everywhere else" number shown to the user. */
142
146
  deleteOtherSessions(userId: string, keepSessionId: string): Promise<number>;
@@ -227,7 +231,7 @@ export interface ApiKeyStore {
227
231
  }
228
232
 
229
233
  /**
230
- * The full seam. One blessed implementation ships (`BuiltinAdapter`); Better Auth, or any
234
+ * The full seam. One blessed implementation ships (`PostgresAuthAdapter`); Better Auth, or any
231
235
  * other identity backend, binds by implementing this and nothing else changes upstream.
232
236
  */
233
237
  export interface AuthAdapter
@@ -32,8 +32,8 @@ export interface ApiKeyResolverOptions extends ApiKeyActorOptions {
32
32
  * `defineAppMcp()` — one resolver for both.
33
33
  *
34
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
35
+ * its module is evaluated, and `postgresAuthAdapter()` takes the process's database client, which
36
+ * boot installs later. `() => memoryAuthAdapter()` would be a new, empty store per request — hold
37
37
  * the instance and return it.
38
38
  */
39
39
  export function apiKeyResolver(
package/src/api-keys.ts CHANGED
@@ -4,12 +4,12 @@
4
4
  // evaluation a human does. The plaintext is shown once; only its SHA-256 is ever stored, and
5
5
  // lookup happens by the non-secret id so the secret never appears in a query, an index or a log.
6
6
 
7
- import { type Clock, randomHex, systemClock } from '@ultimat3/core';
7
+ import { type Clock, randomHex, systemClock, timingSafeEqual } from '@ultimat3/core';
8
8
  import type { ApiKeyStore, AuthApiKeyRecord, AuthUser, UserStore } from './adapter';
9
9
  import { apiKeyEnvInvalid, apiKeyInvalid, apiKeyScopeWildcard } from './errors';
10
10
  import type { PolicyActor } from './policy-bridge';
11
11
  import { actorFromApiKey, isWildcardScope } from './policy-bridge';
12
- import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
12
+ import { randomToken, sha256Hex } from './tokens';
13
13
 
14
14
  export const API_KEY_NAMESPACE = 'ult';
15
15
 
package/src/auth.ts CHANGED
@@ -3,14 +3,14 @@
3
3
  // (http, actions, jobs, MCP) authorizes on that and never on a session row, so there is one
4
4
  // identity shape in the framework and `@ultimat3/policy` is the only thing that reads it.
5
5
 
6
- import { type Clock, systemClock, uuid } from '@ultimat3/core';
6
+ import { type Clock, systemClock, timingSafeEqual, uuidV7 } from '@ultimat3/core';
7
7
  import { t } from '@ultimat3/schema';
8
8
  import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
9
9
  import { normaliseEmail } from './email';
10
10
  import { mfaRequiredUnenforceable, sessionUnknown } from './errors';
11
11
  import { installedAuthLimiter } from './limiter-install';
12
12
  import { openLoginAttempt } from './login-attempt';
13
- import { createTotpReplayGuard, type TotpReplayGuard } from './mfa';
13
+ import { memoryTotpReplayGuard, type TotpReplayGuard } from './mfa';
14
14
  import { mfaChallengeRequired } from './mfa-challenge';
15
15
  import type { OAuthProviderId } from './oauth';
16
16
  import {
@@ -27,9 +27,9 @@ import {
27
27
  type AuthRateLimitPolicy,
28
28
  accountKey,
29
29
  assertAuthLimiterPolicy,
30
- createAuthLimiter,
31
30
  DEFAULT_AUTH_RATE_LIMIT,
32
31
  loginFailed,
32
+ memoryAuthLimiter,
33
33
  orgRateLimit,
34
34
  } from './rate-limit';
35
35
  import {
@@ -41,7 +41,7 @@ import {
41
41
  sessionCookie,
42
42
  verifySession,
43
43
  } from './session';
44
- import { sha256Hex, timingSafeEqual } from './tokens';
44
+ import { sha256Hex } from './tokens';
45
45
 
46
46
  // The projections safe to hand to a client or an MCP tool: no password hash, no TOTP secret,
47
47
  // no token hash. The private columns live in `adapter.ts` and never leave the server.
@@ -136,7 +136,7 @@ export interface AuthConfigInput {
136
136
  readonly password?: Partial<PasswordPolicy> | undefined;
137
137
  readonly rateLimit?: Partial<AuthRateLimitPolicy> | undefined;
138
138
  /**
139
- * Where failed attempts are counted. Omitted means `createAuthLimiter`, which is one process'
139
+ * Where failed attempts are counted. Omitted means `memoryAuthLimiter`, which is one process'
140
140
  * worth of state — correct for dev and tests, and `maxAttempts × N` for N replicas. An app that
141
141
  * runs more than one declares `rateLimit.scope: 'shared'` and passes a limiter that says the
142
142
  * same, or `defineAuth` refuses here rather than at 3am on the first spray.
@@ -150,7 +150,7 @@ export interface AuthConfigInput {
150
150
  readonly mfa?: Partial<AuthMfaPolicy> | undefined;
151
151
  /**
152
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;
153
+ * `memoryTotpReplayGuard()` — one process' worth of memory, so a code is single-use per replica;
154
154
  * a fleet that needs it single-use everywhere passes a shared guard with the same two methods.
155
155
  */
156
156
  readonly totpReplay?: TotpReplayGuard | undefined;
@@ -197,13 +197,13 @@ export function defineAuth(config: AuthConfigInput): Auth {
197
197
  // scaffolded app had to remember to build a shared limiter itself, which is the opposite of
198
198
  // what this framework promises — and `x new` scaffolds two replicas.
199
199
  const limiter =
200
- config.limiter ?? installedAuthLimiter(rateLimit) ?? createAuthLimiter(clock, rateLimit);
200
+ config.limiter ?? installedAuthLimiter(rateLimit) ?? memoryAuthLimiter(clock, rateLimit);
201
201
  assertAuthLimiterPolicy(rateLimit, limiter);
202
202
  // The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
203
203
  // that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
204
204
  // for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
205
205
  // throughput ceiling and discloses nothing. Only the LOCAL fallback is exempt, and exempting it
206
- // is what buys that: `createAuthLimiter` always reports `'process'`, which is the one arm the
206
+ // is what buys that: `memoryAuthLimiter` always reports `'process'`, which is the one arm the
207
207
  // scope check would refuse. A limiter somebody else supplied — injected by the app or built by
208
208
  // the host's factory — is compared exactly as the general bucket's is, because a factory that
209
209
  // ignores the policy it was handed otherwise enforces numbers the app never declared while
@@ -211,7 +211,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
211
211
  // is refused for one bucket and trusted for the other.
212
212
  const orgLimits = orgRateLimit(rateLimit);
213
213
  const suppliedOrgLimiter = config.orgLimiter ?? installedAuthLimiter(orgLimits);
214
- const orgLimiter = suppliedOrgLimiter ?? createAuthLimiter(clock, orgLimits);
214
+ const orgLimiter = suppliedOrgLimiter ?? memoryAuthLimiter(clock, orgLimits);
215
215
  if (suppliedOrgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, suppliedOrgLimiter);
216
216
  // Read through a widened local on purpose: the field's type is the literal `false`, so this
217
217
  // branch is unreachable from TypeScript and reachable from every JS caller and every config
@@ -232,7 +232,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
232
232
  orgRateLimit: orgLimiter.policy,
233
233
  orgLimiter,
234
234
  mfa,
235
- totpReplay: config.totpReplay ?? createTotpReplayGuard(),
235
+ totpReplay: config.totpReplay ?? memoryTotpReplayGuard(),
236
236
  // BREAKING (majors only): the default is `[]`, never the live registry.
237
237
  //
238
238
  // It was `oauthProviderIds()`, so `defineAuth({ providers })`'s own documented purpose — the
@@ -259,7 +259,7 @@ export interface RegisterInput {
259
259
  export async function register(auth: Auth, input: RegisterInput): Promise<AuthUser> {
260
260
  checkPasswordStrength(input.password, { policy: auth.password });
261
261
  return await auth.adapter.createUser({
262
- id: uuid(auth.clock),
262
+ id: uuidV7(auth.clock),
263
263
  email: normaliseEmail(input.email),
264
264
  passwordHash: await hashPassword(input.password, auth.password.params),
265
265
  orgId: input.orgId ?? null,
@@ -1,4 +1,4 @@
1
- // Single responsibility: an `x_users` unique violation as the code `MemoryAdapter` answers. One
1
+ // Single responsibility: an `x_users` unique violation as the code `MemoryAuthAdapter` answers. One
2
2
  // duplicate signup must not be `X_AUTH_WRITE_FAILED` under `x dev` and `X_DB_UNIQUE_VIOLATION` in
3
3
  // production: an app that branches on the code passes its tests and misses on Postgres.
4
4
 
@@ -117,13 +117,13 @@ const toApiKey = (row: Row): AuthApiKeyRecord => ({
117
117
  * assembled into dynamic SQL: `null` stays a meaningful value ("clear this field") and the
118
118
  * statement text remains constant, so the query plan is cached and nothing is interpolated.
119
119
  */
120
- export class BuiltinAdapter implements AuthAdapter {
120
+ export class PostgresAuthAdapter implements AuthAdapter {
121
121
  readonly name = 'builtin-postgres';
122
122
  readonly #db: DbClient;
123
123
  readonly #clock: Clock;
124
124
 
125
125
  /**
126
- * `clock` is the one `MemoryAdapter` takes, for the same stamp: hand over the clock
126
+ * `clock` is the one `MemoryAuthAdapter` takes, for the same stamp: hand over the clock
127
127
  * `defineAuth` was given, so a redemption is dated by the clock that judged its expiry.
128
128
  */
129
129
  constructor(client: DbClient = db(), clock: Clock = systemClock) {
@@ -237,7 +237,9 @@ export class BuiltinAdapter implements AuthAdapter {
237
237
  * One statement, and the two filters are conditional predicates rather than assembled SQL, for
238
238
  * the reason `updateUser`'s columns are: the statement text stays constant and nothing is
239
239
  * interpolated. Disabled members are excluded by default — an access review reads who can sign
240
- * 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.
241
243
  */
242
244
  async listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]> {
243
245
  const role = query?.role ?? null;
@@ -246,7 +248,7 @@ export class BuiltinAdapter implements AuthAdapter {
246
248
  where org_id = ${orgId}
247
249
  and (${query?.includeDisabled === true} or disabled_at is null)
248
250
  and (${role === null} or ${role} = any (roles))
249
- order by email asc`);
251
+ order by email collate "C" asc`);
250
252
  return rows.map(toUser);
251
253
  }
252
254
 
@@ -422,3 +424,14 @@ export class BuiltinAdapter implements AuthAdapter {
422
424
  return changed > 0;
423
425
  }
424
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
+ }
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
  /**