@ultimat3/auth 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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`
@@ -40,10 +40,13 @@ const { start, callback } = oauthLogin(auth);
40
40
  - The lockout counts attempts against one identity, so it has to be **one** count. `AuthLimiter`
41
41
  is async on every member and declares the policy it enforces; `defineAuth` refuses a limiter
42
42
  that disagrees with the app's declaration.
43
+ - An attempt is **reserved** before the KDF, never checked and recorded after it: `reserve(key)`
44
+ counts the guess in the step that admits it, so a concurrent burst spends the allowance as it
45
+ arrives. `refund(reservation)` gives one back on success; a failure calls nothing.
43
46
 
44
47
  ## The lockout across replicas
45
48
 
46
- `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
47
50
  account survive 15 guesses and hides each replica's lockout from the other two. An app that runs
48
51
  more than one process says so and brings a limiter that says the same:
49
52
 
@@ -70,8 +73,10 @@ refuses any pairing that would make it a lie:
70
73
  `account:<email>` and `ip:<addr>` were the only key shapes, so one tenant's misconfigured
71
74
  integration hammering login from 400 addresses was capped by neither — each IP bucket allowed its
72
75
  own quota, the account buckets protected individuals, and the shared limiter saturated behind
73
- them. `orgKey(orgId)` is the third shape, checked in `login()` once the address resolves to an org
74
- and still before the KDF runs.
76
+ them. `orgKey(orgId)` is the third shape, reserved in `login()` once the address resolves to an org
77
+ and still before the KDF runs. A locked tenant answers `X_ACCOUNT_LOCKED` with no org id in it —
78
+ the id was looked up from the address the caller typed — and an attempt the tenant cap turns away
79
+ is not counted against the account or the address.
75
80
 
76
81
  `rateLimit.orgMaxAttempts` is its own number, defaulting to `maxAttempts * 20`: a whole tenant
77
82
  sharing five attempts is a denial of service against that tenant. A success on one member's
@@ -91,7 +96,7 @@ const { identity } = await verifyWorkloadToken({
91
96
  token,
92
97
  issuers: ['https://kubernetes.default.svc'],
93
98
  audience: 'https://ledger.internal',
94
- 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' }),
95
100
  clock,
96
101
  });
97
102
  const actor = actorFromService(identity); // kind: 'service', id: the caller's own sub
@@ -103,14 +108,18 @@ exemption here — the token arrived in a header.
103
108
 
104
109
  **mTLS is out of scope.** TLS termination is the mesh's job ([axiom
105
110
  7](../../docs/idea/README.md)); the framework's part is reading a trusted
106
- `x-forwarded-client-cert` through `@ultimat3/http`'s trusted-proxy seam, which that package owns.
111
+ `x-forwarded-client-cert` through `@ultimat3/http`'s trusted-proxy seam, which that package owns —
112
+ and only once the app declares `configureHttp({ trustClientCertHeader: true })`: trusting the
113
+ proxy's `x-forwarded-for` is not a promise that it strips a certificate header the client sent.
107
114
 
108
115
  `maxKeys` is not compared — it bounds one process' table, not a limit. A custom limiter therefore
109
116
  does **not** own its own configuration: the policy stays the app's single statement of the limits,
110
117
  and the boot check is what keeps it true.
111
118
 
112
- **A shared limiter ships, `As of 2026-08`** — `postgresAuthLimiter({ executor, clock, policy })`,
113
- two tables, a row per failure so the window still SLIDES across replicas. Until it landed,
119
+ **A shared limiter ships, `As of 2026-10`** — `postgresAuthLimiter({ executor, clock, policy })`,
120
+ one table (`x_auth_lockouts`), one row per key holding the instants still inside the window, so
121
+ the window still SLIDES across replicas and a take is ONE statement: forty concurrent guesses
122
+ against `maxAttempts: 5` are admitted five times. Until it landed,
114
123
  `scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
115
124
  `replicas: 2` — `maxAttempts × 2` guesses per account. `executor` is a `PgExecutor`, anything
116
125
  speaking `query(text, values)`; **never `Bun.sql`**, whose `.query` is `undefined`.
@@ -152,7 +161,17 @@ defineAuth({
152
161
  Both limiters share one table: the keys are prefixed (`account:`, `ip:`, `org:`) and every limit
153
162
  travels as a statement parameter, so the tenant bucket's wider allowance cannot leak into the
154
163
  account bucket's. It reports `maxKeys: undefined` — there is no in-process table to bound — and
155
- neither table forgets on its own.
164
+ the table does not forget on its own.
165
+
166
+ A limiter of your own implements the same five members:
167
+
168
+ | Member | Contract |
169
+ |---|---|
170
+ | `reserve(key)` | count one attempt atomically; `X_ACCOUNT_LOCKED`, counting nothing, while the key is locked; the attempt that fills the window is admitted and starts the lockout. Answers `{ key, atMs }` |
171
+ | `refund(reservation)` | remove that ONE attempt, and lift a lockout the window no longer justifies |
172
+ | `recordSuccess(key)` | clear the key — its window and its lockout |
173
+ | `lockedUntil(key)` / `reset()` | the live deadline or `null`; drop everything |
174
+ | `purgeExpired?()` | only for a limiter backed by a table |
156
175
 
157
176
  **An app does not have to write any of that, `As of 2026-08-22`.** The boot fills a seam and every
158
177
  `defineAuth` in the process picks it up:
@@ -174,14 +193,14 @@ A **factory** and not a limiter, because the host runs before the app: `defineAu
174
193
  limiter enforces against what the app declared, so a limiter built at boot on the framework
175
194
  defaults would be `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned its numbers. The
176
195
  factory is called once per bucket, with the resolved policy, so the two halves cannot disagree.
177
- Precedence is `defineAuth({ limiter })` → the installed factory → `createAuthLimiter`, and
196
+ Precedence is `defineAuth({ limiter })` → the installed factory → `memoryAuthLimiter`, and
178
197
  `resetAuthLimiters()` puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
179
198
  on every boot, so a scaffolded app gets a fleet-wide lockout with nothing to remember.
180
199
 
181
- Neither table forgets on its own, and `purgeAuthLimits()` is the framework's reader for that:
182
- it drops failures past the window and lockouts that have expired, measured against the clock the
183
- host handed the limiter, and it sweeps only the WIDEST window installed — a sweep on a narrower
184
- one deletes failures another limiter is still counting, which hands a sprayer its attempts back.
200
+ The table does not forget on its own, and `purgeAuthLimits()` is the framework's reader for that:
201
+ it drops every key whose window has emptied and whose lockout has expired, measured against the
202
+ clock the host handed the limiter, and it sweeps only the WIDEST window installed — a sweep on a
203
+ narrower one deletes attempts another limiter is still counting, which hands a sprayer them back.
185
204
  `@ultimat3/jobs`' `purge()` job is what calls it hourly; `x dev` and every role container declare
186
205
  that sweep at boot.
187
206
 
@@ -222,12 +241,13 @@ registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'ht
222
241
  | `registerOAuthProvider(provider)` | the frozen provider; `X_OAUTH_PROVIDER_DUPLICATE` on a second claim of one id |
223
242
  | `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
224
243
  | `hasOAuthProvider(id)` | whether the id is registered |
225
- | `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal names |
244
+ | `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal is built from, and it is named in the `auth.oauth.refused` log line, never in the response body |
226
245
  | `oauthProviderIds()` | every registered id, live. **NOT** what `defineAuth({ providers })` defaults to — that is `[]`, so an app names what it enabled |
227
246
 
228
247
  `discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
229
248
  to check an id token's signature against, and the token-endpoint TLS exemption below is a thing a
230
- caller declares, not one a provider inherits by omission.
249
+ caller declares, not one a provider inherits by omission. It refuses a document whose `issuer` is
250
+ not the one asked for, too (OIDC Discovery §4.3).
231
251
 
232
252
  **SAML is out of scope and will stay out of scope.** XML-DSig canonicalisation has no Bun native
233
253
  and implementing it would mean a real dependency in the primitive vocabulary, which
@@ -243,7 +263,7 @@ because a default is what silently makes a second door as trusting as the first.
243
263
  | `keys` | Means |
244
264
  |---|---|
245
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 |
246
- | 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 })` |
247
267
 
248
268
  ```ts
249
269
  const keys = providerJwks(providerFor('bigco-sso'));
@@ -282,43 +302,71 @@ convention and business convention never ships ([axiom
282
302
  **every** login — so removing somebody from a group in the IdP takes effect at their next sign-in,
283
303
  rather than never. A seam that returns the stored answer writes nothing.
284
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
+
285
330
  ## MFA — TOTP and recovery codes
286
331
 
287
- Pure functions over a secret and a clock. This package mints, checks and de-duplicates a code;
288
- **it persists nothing** — the secret, the recovery-code hashes and the spent steps are the app's
289
- rows, because `AuthAdapter` has no MFA member and adding one would break every third-party
290
- adapter.
332
+ TOTP is pure functions over a secret and a clock; what is STORED goes through two writers and one
333
+ reader, `As of 2026-10`: the secret is sealed at rest, and a recovery code is consumed atomically.
291
334
 
292
335
  ```ts
293
336
  import type { Auth } from '@ultimat3/auth';
294
- import { createTotpReplayGuard, enrolTotp, generateRecoveryCodes, verifyTotp } from '@ultimat3/auth';
295
- import { systemClock } from '@ultimat3/core';
337
+ import { enrolTotp, generateRecoveryCodes, saveTotpSecret } from '@ultimat3/auth';
296
338
 
297
339
  declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
340
+ declare const userId: string;
298
341
 
299
342
  const enrolment = enrolTotp(auth, { account: 'ada@example.com' }); // issuer: auth.mfa.issuer
300
- // enrolment.uri -> otpauth://… the QR code
301
- // enrolment.secret -> base32, store it against the user
302
-
303
- const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE, store `hashes`
304
- const guard = createTotpReplayGuard();
305
-
306
- export function secondFactorHolds(userId: string, secret: string, code: string): boolean {
307
- const at = systemClock.now();
308
- const { ok, step } = verifyTotp({ secret, code, at });
309
- if (!ok || step === null || guard.isUsed(userId, step)) return false;
310
- guard.remember(userId, step, at);
311
- return true;
312
- }
343
+ // enrolment.uri -> otpauth://… the QR code
344
+ await saveTotpSecret(auth, userId, enrolment.secret); // sealed, onto x_users
345
+
346
+ const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE
347
+ await auth.adapter.updateUser(userId, { recoveryCodeHashes: recovery.hashes });
313
348
  ```
314
349
 
350
+ Checking a code at sign-in is `completeMfa` (below) — it opens the secret, meters the guess and
351
+ refuses a spent code; an app does not call `verifyTotp` on a stored secret itself.
352
+
353
+ **The secret at rest is sealed** under the app's master key (`purpose: 'auth:x_users.mfa_secret'`).
354
+ A TOTP seed is symmetric, so it cannot be a digest — and in the clear, a dump of `x_users` was
355
+ every enrolled user's second factor.
356
+
357
+ | | |
358
+ |---|---|
359
+ | write | `saveTotpSecret(auth, userId, secret)` — refuses a secret nothing can derive a code from (`X_MFA_SECRET_INVALID`), needs the master key (`X_SEAL_KEY_MISSING`) |
360
+ | read | `openTotpSecret(stored)` — **refuses a value that is not sealed** (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). There is no plaintext fallback, by decision: a fallback kept "for now" is a plaintext column forever |
361
+ | rows from before sealing | `x auth seal-mfa` once, or `sealMfaSecrets({ adapter })` on an adapter of your own — idempotent, answers `{ sealed, alreadySealed, skipped }`; each write is a compare-and-set (`adapter.replaceMfaSecret`), so a re-enrolment mid-run is `skipped`, never overwritten. `x doctor` reports how many are left |
362
+
315
363
  | Call | Answers |
316
364
  |---|---|
317
365
  | `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
318
366
  | `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
319
- | `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 |
320
368
  | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
321
- | `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
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 |
322
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 |
323
371
 
324
372
  **A secret the decoder cannot read verifies nothing, `As of 2026-08`.** `base32Decode` answers
@@ -360,11 +408,49 @@ at `login()` instead is a lockout — `actorFromUser` degrades only a user who H
360
408
  package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
361
409
  `if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
362
410
 
363
- **The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
364
- throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
365
- `createSession({ mfaSatisfied: true })` in the app's own route. The framework ships no
366
- `POST /auth/mfa/verify`, deliberately — see [`CLAUDE.md`](CLAUDE.md) for the three things that
367
- would have to land together, and why fewer is worse than none.
411
+ **The second leg of login is `completeMfa`, `As of 2026-10`.** `login()` and
412
+ `completeOAuthLogin()` throw `X_MFA_REQUIRED` before any session exists, and the error's
413
+ `meta.challenge` is what carries the proven first leg to the second: a value sealed under the
414
+ app's master key (`x secrets init`; `X_SEAL_KEY_MISSING` without one), naming the user, good for
415
+ `MFA_CHALLENGE_TTL_MS` (five minutes) on the server's clock. It is `meta`, never `cause` — the
416
+ problem document publishes `cause` — so the sign-in handler decides how it travels.
417
+
418
+ ```ts
419
+ import { type Auth, completeMfa, login } from '@ultimat3/auth';
420
+ import { isUltimateError } from '@ultimat3/core';
421
+
422
+ declare const auth: Auth;
423
+
424
+ export async function signIn(email: string, password: string, ip: string) {
425
+ try {
426
+ return { session: await login(auth, { email, password, ip }) };
427
+ } catch (error) {
428
+ if (!isUltimateError(error) || error.code !== 'X_MFA_REQUIRED') throw error;
429
+ return { challenge: String(error.meta?.['challenge']) }; // a cookie, or your own body
430
+ }
431
+ }
432
+
433
+ export const secondFactor = (challenge: string, code: string, ip: string) =>
434
+ completeMfa(auth, challenge, code, { ip }); // what `login()` answers: actor, session, cookie
435
+ ```
436
+
437
+ | `completeMfa` is given | Answer |
438
+ |---|---|
439
+ | a live challenge and the current code, or an unused recovery code | `{ actor, session, token, cookie }`, `mfaSatisfied: true` |
440
+ | a wrong code, or one already spent | `X_UNAUTHENTICATED`; the guess stays counted |
441
+ | a value this app did not seal, an expired one, or one for an account now disabled or un-enrolled | `X_UNAUTHENTICATED`, one refusal for all of them |
442
+ | a guess past `rateLimit.maxAttempts` | `X_ACCOUNT_LOCKED` — the same account, address and tenant buckets a password guess spends |
443
+
444
+ A code guess is reserved before it is compared, so six digits are worth `maxAttempts` tries per
445
+ window. Repeating the first leg does not buy them back: a proven password with a second factor
446
+ still owed clears nothing — `completeMfa` is what clears the account's window. A spent step is
447
+ refused through `auth.totpReplay` (`defineAuth({ totpReplay })`, in-process by default). No route
448
+ ships.
449
+
450
+ `code` is either the six digits or a **recovery code**: anything that is not six digits is hashed
451
+ and consumed through the adapter, metered exactly as a wrong digit string is, and gone after one
452
+ use. A stored secret that is not sealed is `X_MFA_SECRET_UNSEALED` (HTTP 500) before any guess is
453
+ counted — the deployment's fault, never the caller's.
368
454
 
369
455
  ## Email verification and password reset
370
456
 
@@ -377,14 +463,14 @@ import {
377
463
  consumeVerification,
378
464
  issueVerification,
379
465
  type MailSender,
380
- MemoryAdapter,
466
+ memoryAuthAdapter,
381
467
  type VerificationRuntime,
382
468
  } from '@ultimat3/auth';
383
469
  import { systemClock } from '@ultimat3/core';
384
470
 
385
471
  declare const mail: MailSender; // the app wires @ultimat3/mail's `send` here
386
472
 
387
- const runtime: VerificationRuntime = { store: new MemoryAdapter(), clock: systemClock, mail };
473
+ const runtime: VerificationRuntime = { store: memoryAuthAdapter(), clock: systemClock, mail };
388
474
 
389
475
  const issued = await issueVerification(runtime, {
390
476
  purpose: 'password-reset',
@@ -422,9 +508,15 @@ unauthenticated POST with any token cannot kill the victim's live link.
422
508
  | `revokeUserSessions(auth, userId, reason)` | one person, their current session included |
423
509
  | `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
424
510
  | `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
425
- | `disableUser(auth, userId, reason)` | stamps `disabledAt` **and** kills the sessions |
426
- | `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries |
427
- | `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires |
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 |
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 |
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 |
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.
428
520
 
429
521
  `reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
430
522
  an incident review asks who killed these sessions and why, and a `delete` with no line answers
@@ -444,8 +536,8 @@ an adapter implementation, not a dependency of this package.
444
536
 
445
537
  | Driver | Use |
446
538
  |---|---|
447
- | `BuiltinAdapter` | Postgres via `@ultimat3/db`; takes an injected `DbClient` |
448
- | `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 |
449
541
  | your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
450
542
 
451
543
  **An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
@@ -454,10 +546,8 @@ would not. Normalisation happens once, above the seam, in `normaliseEmail` (`ema
454
546
  lowercase, nothing else. Call it before `findUserByEmail`/`createUser` in any login route of your
455
547
  own, and key any bucket of your own with it — `accountKey` does.
456
548
 
457
- The seam's newer members — `findUserByExternalId`, `listUsersByOrg`, `deleteSessionsForUser`,
458
- `deleteSessionsForOrg`, `deleteSessionsCreatedBefore` — are **optional**, so a 1.2-era adapter
459
- still satisfies the interface. Calling one an adapter has not implemented is `X_NOT_IMPLEMENTED`
460
- with the method named, not a silent no-op.
549
+ Every member of the seam is required: an adapter missing one is a compile error, never a runtime
550
+ refusal.
461
551
 
462
552
  `x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
463
553
  `org_id` index.
@@ -490,7 +580,10 @@ row would not be, and nothing does.
490
580
 
491
581
  ## Cookie
492
582
 
493
- `__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.
494
587
 
495
588
  | Attribute | Attack it closes |
496
589
  |---|---|
@@ -500,6 +593,13 @@ row would not be, and nothing does.
500
593
  | `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
501
594
  | `Max-Age` | a client keeping it past the server's absolute ceiling |
502
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
+
503
603
  ## Signing out
504
604
 
505
605
  `logout(auth, token)` ends the session row. The **response** does the rest: append
@@ -536,49 +636,64 @@ the previous scope's persisted rows and queued writes).
536
636
  `beginOAuth`, the handshake cookie and `completeOAuthLogin`. Provider configs stay pure data —
537
637
  importing `oauth.ts` performs no network I/O and reads no env.
538
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
+
539
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'] });
540
659
  const { start, callback } = oauthLogin(auth);
541
660
 
542
- Bun.serve({
543
- fetch(request) {
544
- const { pathname } = new URL(request.url);
545
- if (pathname.endsWith('/callback')) return callback.handle(request);
546
- if (pathname.startsWith('/auth/oauth/')) return start.handle(request);
547
- return new Response(null, { status: 404 });
548
- },
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 },
549
667
  });
668
+
669
+ export const runtime: NonNullable<ServeOptions['runtime']> = {
670
+ routes: [start, callback].map(mounted),
671
+ };
550
672
  ```
551
673
 
552
674
  | | `start` | `callback` |
553
675
  |---|---|---|
554
676
  | path | `/auth/oauth/:provider` | `/auth/oauth/:provider/callback` |
555
677
  | success | `302` to the provider, `Set-Cookie: __Host-x_oauth_<provider>` | `303` to `successPath`, `Set-Cookie: __Host-x_session` **and** the handshake cleared |
556
- | failure | the coded JSON body, status per code | the same, handshake cleared either way |
678
+ | failure | coded JSON, status per code: `code` + `title` + `docs`, one fixed `cause`, `fix: x errors explain <CODE> --json` | the same, handshake cleared either way |
557
679
 
558
680
  A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
559
681
  is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
560
682
  a rendered page. A bare `Request` in, a `Response` out: drivable from a test, mountable by any
561
- router that can match a `:param`.
562
-
563
- **The `Bun.serve` above is library usage, not app usage.** An Ultimate app's server is `runRole`
564
- (`apps/web/server.ts` is three lines that call it), and `As of 2026-08` `ServeOptions` has no
565
- routes seam — the route list is built inside `serveApp` and closed. So a second `Bun.serve` in an
566
- app does not extend that server, it stands beside it: on its own socket, outside the pipeline, and
567
- therefore outside `configureAuthenticator`, the rate limiter, the security headers and the
568
- SIGTERM drain. A login flow is the last surface that should be the one running unthrottled and
569
- unheadered.
570
-
571
- Until the seam exists, an app serving these descriptors serves them itself and pays for all of
572
- that itself — a second port to publish and health-check, its own throttle in front of `callback`,
573
- its own security headers, and a drain that does not strand a handshake mid-flight. There is no
574
- 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).
575
686
 
576
687
  **The paths are not configurable.** `X_OAUTH_STATE_INVALID` has always told the caller to restart
577
688
  at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
578
689
  reads. A movable base path is that sentence going stale again.
579
690
 
580
- **Failure is JSON, not a redirect carrying `?error=`.** The callback is the one request whose
581
- failure a developer must read, and there is no `?next=` on the success hop either: an
691
+ `oauthLogin` needs an origin to build the provider's `redirect_uri` from: `baseUrl`, or `APP_URL`.
692
+ With neither, the start leg refuses with `X_ENV_MISSING` rather than trust a request header.
693
+
694
+ **Failure is JSON, not a redirect carrying `?error=`.** The body is the same for every refusal of
695
+ one code; the authored cause and fix are the `auth.oauth.refused` log line, where only an operator
696
+ reads them. The callback is the one request whose failure a developer must read, and there is no `?next=` on the success hop either: an
582
697
  attacker-supplied return target on the endpoint that hands out a session is the classic open
583
698
  redirect, and `nextAfterSignIn` in `@ultimat3/http` is the one implementation of that check.
584
699
 
@@ -649,6 +764,9 @@ JWT signed with the `.p8` key, which Apple expires every six months.
649
764
  the status alone there mints a session from a failed exchange.
650
765
  - An address is only linked to an existing account when **both** sides verified it (`link:
651
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.
652
770
 
653
771
  ## API keys — how an agent authenticates
654
772
 
@@ -656,12 +774,44 @@ JWT signed with the `.p8` key, which Apple expires every six months.
656
774
  looked up by the non-secret id.
657
775
 
658
776
  ```ts
777
+ import { type Auth, apiKeyActor, issueApiKey, verifyApiKey } from '@ultimat3/auth';
778
+
779
+ declare const auth: Auth;
780
+ declare const orgId: string;
781
+
659
782
  const { plaintext, record } = issueApiKey({ env: 'prod', scopes: ['post:publish'], orgId });
660
783
  await auth.adapter.putApiKey(record);
661
- const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
784
+ export const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext)); // kind: 'agent'
785
+ ```
786
+
787
+ `verifyApiKey` answers `{ record, owner }`. A key is its owner's credential, `As of 2026-10`:
788
+
789
+ | The key | What verification does |
790
+ |---|---|
791
+ | names no `userId` | nothing more — a key no user owns is cut by its scopes and its org alone |
792
+ | names a `userId` | re-reads that user from the store. Missing or disabled is the same `X_API_KEY_INVALID` every other rejection is, on the very next use; `disableUser` also revokes the key outright |
793
+
794
+ A key's scopes only ever shrink on the way to the actor — never the owning user's roles, never a
795
+ default set:
796
+
797
+ - `*` and `<resource>:*` are refused at `issueApiKey` (`X_CONFIG_INVALID`), and dropped from a row
798
+ that already holds one.
799
+ - A key a user owns keeps only the scopes that owner's grants cover. This package can read the
800
+ row's `permissions` and `scopes` (`directGrants`) and cannot expand a role — that is
801
+ `@ultimat3/policy`'s — so an app whose users hold roles says what they grant:
802
+
803
+ ```ts
804
+ import { type ApiKeyVerifyStore, apiKeyResolver, directGrants } from '@ultimat3/auth';
805
+ import { expandRoles } from '@ultimat3/policy';
806
+
807
+ declare const keys: ApiKeyVerifyStore;
808
+
809
+ export const resolveKey = apiKeyResolver(() => keys, {
810
+ grantsOf: (owner) => [...directGrants(owner), ...expandRoles(owner.roles)],
811
+ });
662
812
  ```
663
813
 
664
- An api key's scopes become **exactly** the agent actor's scopes — never the owning user's roles.
814
+ Without `grantsOf`, a key owned by a user who holds only roles resolves with no scopes at all.
665
815
 
666
816
  ### The resolver a mount takes
667
817
 
@@ -670,27 +820,28 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
670
820
  for both doors.
671
821
 
672
822
  ```ts
673
- import type { ApiKeyStore } from '@ultimat3/auth';
674
- import { apiKeyResolver, BuiltinAdapter } from '@ultimat3/auth';
823
+ import type { ApiKeyVerifyStore } from '@ultimat3/auth';
824
+ import { apiKeyResolver, postgresAuthAdapter } from '@ultimat3/auth';
675
825
 
676
- let keys: ApiKeyStore | undefined;
826
+ let keys: ApiKeyVerifyStore | undefined;
677
827
 
678
828
  /** `resolveToken: resolveKey` — on the mount, and on `defineAppMcp()`. */
679
829
  export const resolveKey = apiKeyResolver(() => {
680
- keys ??= new BuiltinAdapter();
830
+ keys ??= postgresAuthAdapter();
681
831
  return keys;
682
832
  });
683
833
  ```
684
834
 
685
835
  | Presented | Answer |
686
836
  |---|---|
687
- | a live key | `{ actor, scopes }` — the agent actor for the key's org, and its scopes as a `Set` |
688
- | malformed, unknown, wrong secret, revoked, expired | `null`, all five alike: the mount answers one indistinguishable 401 |
837
+ | a live key | `{ actor, scopes }` — the agent actor for the key's org, and the scopes that actor carries as a `Set` |
838
+ | malformed, unknown, wrong secret, revoked, expired, or owned by a user who is gone or disabled | `null`, all alike: the mount answers one indistinguishable 401 |
689
839
  | a store that FAILS | the throw, untouched — a database that is down is not a wrong key |
690
840
 
691
841
  The store is a THUNK, read when a token is presented: a mount is declared when its module is
692
- evaluated, and `new BuiltinAdapter()` takes the process's database client, which boot installs
693
- later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is an `ApiKeyStore`.
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
844
+ `ApiKeyVerifyStore` (an `ApiKeyStore` that can also `findUserById`).
694
845
 
695
846
  ## Errors
696
847
 
@@ -699,6 +850,7 @@ later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is a
699
850
  | `X_UNAUTHENTICATED` | no actor, unknown session, or any failed credential path |
700
851
  | `X_SESSION_EXPIRED` | idle or absolute expiry, named in `cause` |
701
852
  | `X_MFA_REQUIRED` | password proven, second factor outstanding |
853
+ | `X_MFA_SECRET_UNSEALED` | a stored `mfa_secret` is not sealed; it is refused, never read. `x auth seal-mfa --json` |
702
854
  | `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
703
855
  | `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
704
856
  | `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` |
@@ -706,10 +858,10 @@ later. In a test the store is `new MemoryAdapter()` — every `AuthAdapter` is a
706
858
  | `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
707
859
  | `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
708
860
  | `X_PASSWORD_WEAK` | strength check rejected the password |
709
- | `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout |
710
- | `X_API_KEY_INVALID` | key unknown, revoked, expired or wrong |
861
+ | `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout. The cause names the caller's own address or email, never an org |
862
+ | `X_API_KEY_INVALID` | key unknown, revoked, expired, wrong, or owned by a user who is gone or disabled |
711
863
  | `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
712
- | `X_NOT_IMPLEMENTED` | an `AuthAdapter` has not implemented an optional seam member (`revokeOrgSessions`, `listOrgUsers`, …), or lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
864
+ | `X_AUTH_WRITE_FAILED` | an `AuthAdapter` lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
713
865
 
714
866
  ```bash
715
867
  bun test packages/auth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "23.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": "23.0.0",
36
- "@ultimat3/db": "23.0.0",
37
- "@ultimat3/schema": "23.0.0"
35
+ "@ultimat3/core": "25.0.0",
36
+ "@ultimat3/db": "25.0.0",
37
+ "@ultimat3/schema": "25.0.0"
38
38
  }
39
39
  }