@ultimat3/auth 1.2.0 → 3.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,17 +5,21 @@ 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 } from '@ultimat3/auth';
8
+ import { BuiltinAdapter, defineAuth, login, oauthLogin } from '@ultimat3/auth';
9
9
 
10
10
  export const auth = defineAuth({
11
11
  adapter: new BuiltinAdapter(), // or MemoryAdapter, or your Better Auth binding
12
12
  session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
13
13
  password: { minLength: 12 },
14
- mfa: { issuer: 'Acme' },
14
+ mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
15
15
  providers: ['github', 'google'],
16
+ link: 'verified-email', // the default; `'never'` is the only other value
16
17
  });
17
18
 
18
19
  const { actor, token, cookie } = await login(auth, { email, password, ip });
20
+
21
+ // "Log in with GitHub" is a link to /auth/oauth/github. These two routes are what serves it.
22
+ const { start, callback } = oauthLogin(auth);
19
23
  ```
20
24
 
21
25
  ## Rules
@@ -23,9 +27,327 @@ const { actor, token, cookie } = await login(auth, { email, password, ip });
23
27
  - Every login failure throws `loginFailed()` from `rate-limit.ts`. Never a specific message.
24
28
  - Session ids are opaque random tokens; only `sha256(secret)` reaches the database.
25
29
  - Absolute and idle expiry are evaluated **independently**. Activity never moves the ceiling.
26
- - PKCE is mandatory on every provider. A missing verifier fails the callback.
30
+ - PKCE is mandatory on every provider — `OAuthProvider.usesPkce` is the literal `true`, so
31
+ `usesPkce: false` does not typecheck and there is no branch that skips the verifier.
27
32
  - Recovery codes, verification tokens and api keys are hashed at rest and single-use.
33
+ - A verification token is consumed **only when its hash matches**, in the same statement — the
34
+ store takes `(purpose, identifier, tokenHash)`. Consuming first and comparing afterwards made an
35
+ unauthenticated wrong guess destroy the victim's live reset link.
28
36
  - Guards assert on the actor. They never evaluate a policy.
37
+ - A user's `scopes` column reaches `Actor.scopes`, so a human can hold a scope. `permissions` is a
38
+ different field and `hasScope()` does not read it.
39
+ - `verifySession` writes at most once per `idleSlideMs`, not once per request.
40
+ - The lockout counts attempts against one identity, so it has to be **one** count. `AuthLimiter`
41
+ is async on every member and declares the policy it enforces; `defineAuth` refuses a limiter
42
+ that disagrees with the app's declaration.
43
+
44
+ ## The lockout across replicas
45
+
46
+ `createAuthLimiter` keeps its table in the process, so `maxAttempts: 5` at `replicas: 3` lets an
47
+ account survive 15 guesses and hides each replica's lockout from the other two. An app that runs
48
+ more than one process says so and brings a limiter that says the same:
49
+
50
+ ```ts
51
+ defineAuth({
52
+ adapter,
53
+ rateLimit: { maxAttempts: 5, scope: 'shared' }, // the whole fleet's allowance
54
+ limiter: myLimiter, // whose policy says exactly the same
55
+ });
56
+ ```
57
+
58
+ `Auth.rateLimit` is what an operator reads as "what this deployment enforces", so `defineAuth`
59
+ refuses any pairing that would make it a lie:
60
+
61
+ | Declared | Limiter's own `policy` | Result |
62
+ |---|---|---|
63
+ | `scope: 'process'` (default) | anything, same numbers | boots; the lockout is per replica |
64
+ | `scope: 'shared'` | `scope: 'shared'`, same numbers | boots; one count for the fleet |
65
+ | `scope: 'shared'` | `scope: 'process'` | `X_AUTH_LIMITER_NOT_SHARED` |
66
+ | `maxAttempts`/`windowMs`/`lockoutMs` | any of the three different | `X_AUTH_LIMITER_POLICY_MISMATCH` |
67
+
68
+ ### The third bucket: one tenant
69
+
70
+ `account:<email>` and `ip:<addr>` were the only key shapes, so one tenant's misconfigured
71
+ integration hammering login from 400 addresses was capped by neither — each IP bucket allowed its
72
+ 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.
75
+
76
+ `rateLimit.orgMaxAttempts` is its own number, defaulting to `maxAttempts * 20`: a whole tenant
77
+ sharing five attempts is a denial of service against that tenant. A success on one member's
78
+ account clears the **account** window and deliberately not the tenant one — otherwise the traffic
79
+ that proves the tenant is alive is also the traffic that resets its cap. Pass `orgLimiter` to
80
+ `defineAuth` to share those counters across replicas; unlike `limiter`, it is not required under
81
+ `scope: 'shared'`, because a tenant cap is a throughput ceiling and not a guessing allowance.
82
+
83
+ ## Service-to-service
84
+
85
+ `verifyWorkloadToken` is the one function, and it reads all three shapes because they are all the
86
+ same JWT: a Kubernetes projected service-account token, a SPIFFE JWT-SVID, and a cloud IMDS token.
87
+ It is also the shape RFC 8693's `subject_token` takes.
88
+
89
+ ```ts
90
+ const { identity } = await verifyWorkloadToken({
91
+ token,
92
+ issuers: ['https://kubernetes.default.svc'],
93
+ audience: 'https://ledger.internal',
94
+ keys: createJwksClient({ provider: 'k8s', jwksUri: 'https://kubernetes.default.svc/openid/v1/jwks' }),
95
+ clock,
96
+ });
97
+ const actor = actorFromService(identity); // kind: 'service', id: the caller's own sub
98
+ ```
99
+
100
+ Signature first, then issuer, audience, `exp` and `nbf`. `scope` (space-delimited) or `scp` (an
101
+ array) become the actor's scopes; a token with neither carries none. There is no trusted-channel
102
+ exemption here — the token arrived in a header.
103
+
104
+ **mTLS is out of scope.** TLS termination is the mesh's job ([axiom
105
+ 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.
107
+
108
+ `maxKeys` is not compared — it bounds one process' table, not a limit. A custom limiter therefore
109
+ does **not** own its own configuration: the policy stays the app's single statement of the limits,
110
+ and the boot check is what keeps it true. **No shared limiter ships yet, `As of 2026-08`** —
111
+ `createAuthLimiter` is the only implementation in the framework.
112
+
113
+ ## Providers are a registry, not a union
114
+
115
+ `OAuthProviderId` is `string`, and `registerOAuthProvider()` is the one way a provider gets in —
116
+ the three built-ins go through the same call. Before 1.3.0 the id was `keyof typeof
117
+ OAUTH_PROVIDERS` over `github | google | apple`, so an enterprise OP was **unrepresentable**: the
118
+ constraint was a type, there was no runtime escape, and the only ways out were forking the package
119
+ or bypassing OAuth entirely and losing PKCE, the sealed handshake, issuer pinning and account
120
+ linking with it.
121
+
122
+ ```ts
123
+ import { discoverOAuthProvider, registerOAuthProvider } from '@ultimat3/auth';
124
+
125
+ // By hand, when you know the four endpoints:
126
+ registerOAuthProvider({
127
+ id: 'bigco-sso',
128
+ authorizeUrl: 'https://sso.bigco.test/oauth2/v1/authorize',
129
+ tokenUrl: 'https://sso.bigco.test/oauth2/v1/token',
130
+ userInfoUrl: 'https://sso.bigco.test/oauth2/v1/userinfo',
131
+ userEmailsUrl: null,
132
+ issuers: ['https://sso.bigco.test'],
133
+ jwksUri: 'https://sso.bigco.test/oauth2/v1/keys',
134
+ scopes: ['openid', 'email', 'profile'],
135
+ usesPkce: true, // the literal `true`; `false` does not typecheck, for anyone
136
+ usesNonce: true,
137
+ clientIdEnv: 'BIGCO_SSO_CLIENT_ID',
138
+ clientSecretEnv: 'BIGCO_SSO_CLIENT_SECRET',
139
+ });
140
+
141
+ // Or read them once at boot, from the issuer's own discovery document:
142
+ registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'https://sso.bigco.test' }));
143
+ ```
144
+
145
+ | Call | Answers |
146
+ |---|---|
147
+ | `registerOAuthProvider(provider)` | the frozen provider; `X_OAUTH_PROVIDER_DUPLICATE` on a second claim of one id |
148
+ | `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
149
+ | `hasOAuthProvider(id)` | whether the id is registered |
150
+ | `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal names |
151
+ | `oauthProviderIds()` | every registered id, live — `defineAuth({ providers })` defaults to it |
152
+
153
+ `discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
154
+ to check an id token's signature against, and the token-endpoint TLS exemption below is a thing a
155
+ caller declares, not one a provider inherits by omission.
156
+
157
+ **SAML is out of scope and will stay out of scope.** XML-DSig canonicalisation has no Bun native
158
+ and implementing it would mean a real dependency in the primitive vocabulary, which
159
+ [`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md) does not permit. The honest
160
+ answer is to put an OIDC-speaking SAML bridge (Okta, Entra, Keycloak, Dex, a SAML-to-OIDC proxy) in
161
+ front and register **that** as a provider.
162
+
163
+ ## Id token signatures
164
+
165
+ `verifyIdToken({ keys })` is **required** to say where its trust comes from. There is no default,
166
+ because a default is what silently makes a second door as trusting as the first.
167
+
168
+ | `keys` | Means |
169
+ |---|---|
170
+ | `'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 |
171
+ | a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `createJwksClient({ provider, jwksUri })` |
172
+
173
+ ```ts
174
+ const keys = providerJwks(providerFor('bigco-sso'));
175
+ const claims = await verifyIdToken({ provider: 'bigco-sso', idToken, clientId, nonce, clock, keys });
176
+ ```
177
+
178
+ `crypto.subtle` covers RS256 and ES256, so this costs no dependency. `HS256` and `alg: none` are
179
+ refused before a key is even looked up — a symmetric algorithm verified against a *public* key set
180
+ is the classic algorithm-confusion forgery. Keys are cached by `kid` with a TTL and one unknown
181
+ `kid` triggers exactly one refetch, so a rotation heals itself.
182
+
183
+ Wire an unsolicited token — IdP-initiated login, `response_mode=form_post`, back-channel logout,
184
+ token exchange — to anything that does **not** check the signature and an attacker posts a
185
+ self-minted JWT with the right `iss`, your `aud`, a VP's `sub` and tomorrow's `exp`, and gets a
186
+ session. That is account takeover with no credential, and it is what this exists to stop.
187
+
188
+ ## What an SSO user is allowed to do
189
+
190
+ `oauthLogin(auth, { resolveGrants })`. **Omit it and a first-time SSO user is created with
191
+ `roles: []` and `orgId: null`** — an actor every `can()` denies, and a tenant-scoped read that
192
+ throws before the query is built. SSO "works" and the person can do nothing until somebody runs
193
+ SQL, so the omission logs `auth.oauth.user_created_without_grants` rather than passing silently.
194
+
195
+ ```ts
196
+ oauthLogin(auth, {
197
+ resolveGrants: async (profile) => {
198
+ const member = await directory.lookup(profile.email);
199
+ return { orgId: member.orgId, roles: member.groups.map(toRole), scopes: [] };
200
+ },
201
+ });
202
+ ```
203
+
204
+ It is a **seam and not a group-to-role table**: which IdP group means which role is business
205
+ convention and business convention never ships ([axiom
206
+ 8](../../docs/idea/19-mechanism-not-convention.md)). What the framework owns is calling it on
207
+ **every** login — so removing somebody from a group in the IdP takes effect at their next sign-in,
208
+ rather than never. A seam that returns the stored answer writes nothing.
209
+
210
+ ## MFA — TOTP and recovery codes
211
+
212
+ Pure functions over a secret and a clock. This package mints, checks and de-duplicates a code;
213
+ **it persists nothing** — the secret, the recovery-code hashes and the spent steps are the app's
214
+ rows, because `AuthAdapter` has no MFA member and adding one would break every third-party
215
+ adapter.
216
+
217
+ ```ts
218
+ import type { Auth } from '@ultimat3/auth';
219
+ import { createTotpReplayGuard, enrolTotp, generateRecoveryCodes, verifyTotp } from '@ultimat3/auth';
220
+ import { systemClock } from '@ultimat3/core';
221
+
222
+ declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
223
+
224
+ const enrolment = enrolTotp(auth, { account: 'ada@example.com' }); // issuer: auth.mfa.issuer
225
+ // enrolment.uri -> otpauth://… the QR code
226
+ // enrolment.secret -> base32, store it against the user
227
+
228
+ const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE, store `hashes`
229
+ const guard = createTotpReplayGuard();
230
+
231
+ export function secondFactorHolds(userId: string, secret: string, code: string): boolean {
232
+ const at = systemClock.now();
233
+ const { ok, step } = verifyTotp({ secret, code, at });
234
+ if (!ok || step === null || guard.isUsed(userId, step)) return false;
235
+ guard.remember(userId, step, at);
236
+ return true;
237
+ }
238
+ ```
239
+
240
+ | Call | Answers |
241
+ |---|---|
242
+ | `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
243
+ | `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
244
+ | `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
245
+ | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
246
+ | `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
247
+ | `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code |
248
+
249
+ `TOTP_DIGITS` (6), `TOTP_STEP_SECONDS` (30) and `TOTP_DRIFT_STEPS` (±1 window) are exported so an
250
+ app's own copy of the parameters cannot disagree with the verifier's.
251
+
252
+ A step is remembered per **subject**, not globally: a code is valid for `drift` windows either
253
+ side of now, so without the guard the same six digits log in twice inside a minute. `verifyTotp`
254
+ answers `{ ok: false, step }` — the step still named — when `usedSteps` already holds it, which is
255
+ how a replay is told apart from a wrong code.
256
+
257
+ The guard's table is **bounded** (`DEFAULT_MAX_TOTP_SUBJECTS`, 10,000), because a per-subject map
258
+ that only ever grows is one process' lifetime away from an OOM. A subject whose every remembered
259
+ step has fallen below the drift floor is *forgotten* — `verifyTotp` can never offer that step
260
+ again, so the entry answers exactly as a missing one — and only if that is not enough does the cap
261
+ evict live state, furthest from the live window first. The order is the guarantee: evicting a
262
+ subject makes a step they have already spent replayable, so the subject who just authenticated is
263
+ always the last one out.
264
+
265
+ **`mfa.required` is accepted only as `false`, and that is deliberate.** The field exists and is
266
+ typed as the literal `false`, so `required: true` is a compile error; a `true` that reaches
267
+ `defineAuth` from JavaScript or from JSON — where the type cannot — is refused at boot with
268
+ `X_CONFIG_INVALID` naming the key, never an unknown-key error and never a silent accept. Nothing
269
+ read it: both credential paths branch on `user.mfaSecret` alone, so an un-enrolled user was handed
270
+ a full session under a config that read as "this deployment requires a second factor". Enforcing it
271
+ at `login()` instead is a lockout — `actorFromUser` degrades only a user who HAS a secret, and this
272
+ package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
273
+ `if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
274
+
275
+ **The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
276
+ throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
277
+ `createSession({ mfaSatisfied: true })` in the app's own route. The framework ships no
278
+ `POST /auth/mfa/verify`, deliberately — see [`CLAUDE.md`](CLAUDE.md) for the three things that
279
+ would have to land together, and why fewer is worse than none.
280
+
281
+ ## Email verification and password reset
282
+
283
+ One issue/consume pair, two purposes. The token is mailed and only its `sha256` is stored, and
284
+ consuming it is a single conditional statement — the hash is an **argument to** the consume, never
285
+ a comparison after one.
286
+
287
+ ```ts
288
+ import {
289
+ consumeVerification,
290
+ issueVerification,
291
+ type MailSender,
292
+ MemoryAdapter,
293
+ type VerificationRuntime,
294
+ } from '@ultimat3/auth';
295
+ import { systemClock } from '@ultimat3/core';
296
+
297
+ declare const mail: MailSender; // the app wires @ultimat3/mail's `send` here
298
+
299
+ const runtime: VerificationRuntime = { store: new MemoryAdapter(), clock: systemClock, mail };
300
+
301
+ const issued = await issueVerification(runtime, {
302
+ purpose: 'password-reset',
303
+ identifier: 'ada@example.com', // the address; also the store key
304
+ locale: 'en',
305
+ link: (token) => `https://acme.test/reset?token=${token}`,
306
+ });
307
+
308
+ const verification = await consumeVerification(runtime, {
309
+ purpose: 'password-reset',
310
+ identifier: 'ada@example.com',
311
+ token: issued.token, // in production this arrives off the link
312
+ });
313
+ ```
314
+
315
+ | Name | Is |
316
+ |---|---|
317
+ | `VERIFICATION_PURPOSES` | `['email-verify', 'password-reset']` — the whole set, and `VerificationPurpose` is derived from it |
318
+ | `VERIFICATION_TEMPLATES` | purpose → **catalog key** (`auth.email-verify`, `auth.password-reset`), never copy: the body lives in the app's i18n catalog |
319
+ | `DEFAULT_VERIFICATION_TTL_MS` | 24 h for `email-verify`, 1 h for `password-reset` — a reset link is a password |
320
+ | `MailSender` | the injected port: `send(template, to, data, locale)`. `@ultimat3/mail` is tier 4 and this package is tier 2, so the app wires it |
321
+ | `issueVerification(runtime, input)` | `{ token, expiresAt }`. The token comes back for a test or a CLI; production only ever mails it |
322
+ | `consumeVerification(runtime, input)` | the `AuthVerification` row, or `X_UNAUTHENTICATED`. Unknown, spent, expired and mismatched are one answer |
323
+
324
+ One live token per `(purpose, identifier)`. A wrong guess destroys nothing: the store's
325
+ `takeVerification(purpose, identifier, tokenHash)` matches before it consumes, so an
326
+ unauthenticated POST with any token cannot kill the victim's live link.
327
+
328
+ ## Revocation, offboarding and access review
329
+
330
+ | Call | Blast radius |
331
+ |---|---|
332
+ | `revokeSession(runtime, id)` | one session |
333
+ | `revokeOtherSessions(runtime, userId, keep)` | every session but the caller's |
334
+ | `revokeUserSessions(auth, userId, reason)` | one person, their current session included |
335
+ | `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
336
+ | `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
337
+ | `disableUser(auth, userId, reason)` | stamps `disabledAt` **and** kills the sessions |
338
+ | `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries |
339
+ | `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires |
340
+
341
+ `reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
342
+ an incident review asks who killed these sessions and why, and a `delete` with no line answers
343
+ neither. Each one logs `auth.revocation` before it runs.
344
+
345
+ `deleteSessionsForOrg` joins through `x_users` and `x_sessions` deliberately does **not** gain an
346
+ `org_id`: a denormalised copy of the membership goes stale the moment somebody moves org, and a
347
+ stale row means the sweep leaves live exactly the sessions it was run to kill.
348
+
349
+ `describeUser` is an allow-list, never a delete-list — a column added to `AuthUser` later must not
350
+ appear in an admin response by default. No password hash, no TOTP secret, no recovery-code hash.
29
351
 
30
352
  ## Adapter seam
31
353
 
@@ -38,9 +360,46 @@ an adapter implementation, not a dependency of this package.
38
360
  | `MemoryAdapter` | `x new` before a database exists, and every test in this package |
39
361
  | your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
40
362
 
41
- ```bash
42
- x db gen "auth tables" # emits AUTH_TABLES into a migration
43
- ```
363
+ **An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
364
+ is a plain case-sensitive `text ... unique`, so an adapter that lowercased found accounts Postgres
365
+ would not. Normalisation happens once, above the seam, in `normaliseEmail` (`email.ts`): trim and
366
+ lowercase, nothing else. Call it before `findUserByEmail`/`createUser` in any login route of your
367
+ own, and key any bucket of your own with it — `accountKey` does.
368
+
369
+ The seam's newer members — `findUserByExternalId`, `listUsersByOrg`, `deleteSessionsForUser`,
370
+ `deleteSessionsForOrg`, `deleteSessionsCreatedBefore` — are **optional**, so a 1.2-era adapter
371
+ still satisfies the interface. Calling one an adapter has not implemented is `X_NOT_IMPLEMENTED`
372
+ with the method named, not a silent no-op.
373
+
374
+ `x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
375
+ `org_id` index. `X_USERS_MIGRATION_1_3` is those statements for an app already on 1.2; both
376
+ columns are additive with a default, so the migration takes no table rewrite.
377
+
378
+ **Nothing wires those statements into a migration for you, `As of 2026-08`.** `AUTH_TABLES` is the
379
+ DDL as plain strings (`AUTH_TABLE_NAMES` is what they create, and `X_USERS_TABLE`,
380
+ `X_SESSIONS_TABLE`, `X_ACCOUNTS_TABLE`, `X_API_KEYS_TABLE`, `X_VERIFICATIONS_TABLE` are the
381
+ individual ones). `x db gen <name>` diffs `describeEntities()` against the newest migration's
382
+ snapshot — these tables are not `entity()` declarations, so nothing outside `tables.ts` reads the
383
+ constant and the command cannot see them. Paste each statement into its own file under
384
+ `packages/db/migrations/`, one statement per migration, then `x db migrate`.
385
+
386
+ ## Sessions are not a write path
387
+
388
+ `verifySession` used to `UPDATE x_sessions … RETURNING *` on **every** authenticated request: one
389
+ request was a SELECT, a write and a second SELECT before the app's own first query. At 20k rps
390
+ that is 20k writes a second on one hot table, autovacuum falls behind, and the incident reads as
391
+ "the database is slow".
392
+
393
+ `SessionPolicy.idleSlideMs` — defaulting to `idleTtlMs / 20` — is how far `lastSeenAt` may drift
394
+ before a request writes it forward. A second request inside that window issues no write at all. A
395
+ changed IP or user agent is still written immediately, because that is the row a device list and
396
+ an incident review read. The trade is bounded and explicit: idle expiry is now precise to within
397
+ one `idleSlideMs`.
398
+
399
+ **Revocation is unaffected, and deliberately so.** `authenticate` re-reads the *user* row on every
400
+ request, so a revoked role takes effect on the very next one with no token-expiry lag — a better
401
+ property than any claims-in-a-JWT design. Throttling the session write is safe; caching the user
402
+ row would not be, and nothing does.
44
403
 
45
404
  ## Cookie
46
405
 
@@ -54,38 +413,78 @@ x db gen "auth tables" # emits AUTH_TABLES into a migration
54
413
  | `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
55
414
  | `Max-Age` | a client keeping it past the server's absolute ceiling |
56
415
 
57
- ## OAuth
416
+ ## OAuth — log in with GitHub
58
417
 
59
- Two calls: one to leave, one to come back. Provider configs are pure data — importing
60
- `oauth.ts` performs no network I/O and reads no env.
418
+ `oauthLogin(auth)` **is** the flow. Two route descriptors, mounted at two fixed paths, composing
419
+ `beginOAuth`, the handshake cookie and `completeOAuthLogin`. Provider configs stay pure data —
420
+ importing `oauth.ts` performs no network I/O and reads no env.
61
421
 
62
422
  ```ts
63
- // GET /auth/oauth/:provider — redirect, keeping nothing on the server
64
- export async function GET(request: Request): Promise<Response> {
65
- const handshake = beginOAuth({ provider: 'github', clientId, redirectUri });
66
- return new Response(null, {
67
- status: 302,
68
- headers: { location: handshake.authorizeUrl, 'set-cookie': handshakeCookie(handshake) },
69
- });
70
- }
423
+ const { start, callback } = oauthLogin(auth);
424
+
425
+ Bun.serve({
426
+ fetch(request) {
427
+ const { pathname } = new URL(request.url);
428
+ if (pathname.endsWith('/callback')) return callback.handle(request);
429
+ if (pathname.startsWith('/auth/oauth/')) return start.handle(request);
430
+ return new Response(null, { status: 404 });
431
+ },
432
+ });
71
433
  ```
72
434
 
435
+ | | `start` | `callback` |
436
+ |---|---|---|
437
+ | path | `/auth/oauth/:provider` | `/auth/oauth/:provider/callback` |
438
+ | success | `302` to the provider, `Set-Cookie: __Host-x_oauth_<provider>` | `303` to `successPath`, `Set-Cookie: __Host-x_session` **and** the handshake cleared |
439
+ | failure | the coded JSON body, status per code | the same, handshake cleared either way |
440
+
441
+ A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
442
+ is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
443
+ a rendered page. A bare `Request` in, a `Response` out: drivable from a test, mountable by any
444
+ router that can match a `:param`.
445
+
446
+ **The `Bun.serve` above is library usage, not app usage.** An Ultimate app's server is `runRole`
447
+ (`apps/web/server.ts` is three lines that call it), and `As of 2026-08` `ServeOptions` has no
448
+ routes seam — the route list is built inside `serveApp` and closed. So a second `Bun.serve` in an
449
+ app does not extend that server, it stands beside it: on its own socket, outside the pipeline, and
450
+ therefore outside `configureAuthenticator`, the rate limiter, the security headers and the
451
+ SIGTERM drain. A login flow is the last surface that should be the one running unthrottled and
452
+ unheadered.
453
+
454
+ Until the seam exists, an app serving these descriptors serves them itself and pays for all of
455
+ that itself — a second port to publish and health-check, its own throttle in front of `callback`,
456
+ its own security headers, and a drain that does not strand a handshake mid-flight. There is no
457
+ mounting API to call today; do not write one, and do not read this section as promising one.
458
+
459
+ **The paths are not configurable.** `X_OAUTH_STATE_INVALID` has always told the caller to restart
460
+ at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
461
+ reads. A movable base path is that sentence going stale again.
462
+
463
+ **Failure is JSON, not a redirect carrying `?error=`.** The callback is the one request whose
464
+ failure a developer must read, and there is no `?next=` on the success hop either: an
465
+ attacker-supplied return target on the endpoint that hands out a session is the classic open
466
+ redirect, and `nextAfterSignIn` in `@ultimat3/http` is the one implementation of that check.
467
+
468
+ `beginOAuth` / `handshakeCookie` / `completeOAuthLogin` stay exported for a flow that needs the
469
+ seams — but they are the seams, not the path.
470
+
471
+ ### Account linking
472
+
73
473
  ```ts
74
- // GET /auth/oauth/:provider/callback a separate request; the cookie is all that crossed
75
- export async function GET(request: Request): Promise<Response> {
76
- const url = new URL(request.url);
77
- const { cookie } = await completeOAuthLogin(auth, {
78
- handshake: readHandshakeCookie(request, 'github'),
79
- callback: { state: url.searchParams.get('state') ?? '', code: url.searchParams.get('code') ?? '' },
80
- });
81
- const headers = new Headers({ location: '/' });
82
- // Both, always: a code is single-use, so the handshake that authorised it must not outlive it.
83
- headers.append('set-cookie', cookie);
84
- headers.append('set-cookie', clearHandshakeCookie('github'));
85
- return new Response(null, { status: 302, headers });
86
- }
474
+ defineAuth({ adapter, providers: ['github'], link: 'verified-email' }) // the default
87
475
  ```
88
476
 
477
+ | `link` | a provider identity becomes an **existing** user when |
478
+ |---|---|
479
+ | `'verified-email'` (default) | the provider asserted the address verified **and** that account had verified it too |
480
+ | `'never'` | never — a collision is `X_UNAUTHENTICATED` and the caller uses their own credentials |
481
+
482
+ There is deliberately **no third value**. "Link on whatever address the provider sent" is not
483
+ spelled here at all: a provider that does not verify addresses turns it into account takeover —
484
+ register the victim's address there, press the button, inherit the account. Unrepresentable beats
485
+ explicit, the same way `PkcePair.method` is the literal `'S256'` and never `'plain'`. An app that
486
+ truly wants something looser wraps `signInWithOAuth` and resolves the user itself.
487
+
89
488
  The handshake carries `state`, `nonce` and the PKCE verifier across two requests, so it needs a
90
489
  home. `handshakeCookie` is that home — sealed with `SESSION_SECRET`, `HttpOnly; Secure;
91
490
  SameSite=Lax` under a `__Host-` name, and expired against the server's clock rather than the
@@ -119,6 +518,7 @@ JWT signed with the `.p8` key, which Apple expires every six months.
119
518
 
120
519
  | Step | Does | Fails with |
121
520
  |---|---|---|
521
+ | `oauthLogin(auth)` | the two routes: redirect out, session back | `X_OAUTH_PROVIDER_UNKNOWN`, `X_OAUTH_DENIED` |
122
522
  | `handshakeCookie` / `readHandshakeCookie` | seals the handshake onto the redirect, opens it on the callback | `X_OAUTH_STATE_INVALID`, `X_ENV_MISSING` |
123
523
  | `exchangeOAuthCode` | POSTs the code + PKCE verifier, verifies the id token | `X_OAUTH_EXCHANGE_FAILED`, `X_OAUTH_TOKEN_INVALID` |
124
524
  | `oauthProfile` | id-token claims, else userinfo → one normalised identity | `X_OAUTH_EXCHANGE_FAILED` |
@@ -130,8 +530,8 @@ JWT signed with the `.p8` key, which Apple expires every six months.
130
530
  token, because that is where the code flow actually carries it.
131
531
  - GitHub reports a bad, reused or expired code as **HTTP 200 with an `error` field**. Trusting
132
532
  the status alone there mints a session from a failed exchange.
133
- - An address is only linked to an existing account when **both** sides verified it. Otherwise
134
- whoever registered the address first inherits the login.
533
+ - An address is only linked to an existing account when **both** sides verified it (`link:
534
+ 'verified-email'`). Otherwise whoever registered the address first inherits the login.
135
535
 
136
536
  ## API keys — how an agent authenticates
137
537
 
@@ -155,12 +555,15 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
155
555
  | `X_MFA_REQUIRED` | password proven, second factor outstanding |
156
556
  | `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
157
557
  | `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
158
- | `X_OAUTH_TOKEN_INVALID` | the id token failed its issuer, audience or expiry check |
558
+ | `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` |
559
+ | `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 |
560
+ | `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
561
+ | `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
159
562
  | `X_PASSWORD_WEAK` | strength check rejected the password |
160
- | `X_ACCOUNT_LOCKED` | per-ip or per-account bucket is inside its lockout |
563
+ | `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout |
161
564
  | `X_API_KEY_INVALID` | key unknown, revoked, expired or wrong |
162
565
  | `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
163
- | `X_NOT_IMPLEMENTED` | an `AuthAdapter` refused a method (`authNotImplemented(feature, fix)`), or lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
566
+ | `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 |
164
567
 
165
568
  ```bash
166
569
  bun test packages/auth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "1.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],
@@ -30,8 +31,8 @@
30
31
  "test": "bun test"
31
32
  },
32
33
  "dependencies": {
33
- "@ultimat3/core": "1.2.0",
34
- "@ultimat3/db": "1.2.0",
35
- "@ultimat3/schema": "1.2.0"
34
+ "@ultimat3/core": "3.0.0",
35
+ "@ultimat3/db": "3.0.0",
36
+ "@ultimat3/schema": "3.0.0"
36
37
  }
37
38
  }