@ultimat3/auth 1.2.0 → 2.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,7 +5,7 @@ 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
@@ -13,9 +13,13 @@ export const auth = defineAuth({
13
13
  password: { minLength: 12 },
14
14
  mfa: { issuer: 'Acme' },
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,306 @@ 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 { createTotpReplayGuard, enrolTotp, generateRecoveryCodes, verifyTotp } from '@ultimat3/auth';
219
+ import { systemClock } from '@ultimat3/core';
220
+
221
+ const enrolment = enrolTotp({ issuer: 'Acme', account: 'ada@example.com' });
222
+ // enrolment.uri -> otpauth://… the QR code
223
+ // enrolment.secret -> base32, store it against the user
224
+
225
+ const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE, store `hashes`
226
+ const guard = createTotpReplayGuard();
227
+
228
+ export function secondFactorHolds(userId: string, secret: string, code: string): boolean {
229
+ const at = systemClock.now();
230
+ const { ok, step } = verifyTotp({ secret, code, at });
231
+ if (!ok || step === null || guard.isUsed(userId, step)) return false;
232
+ guard.remember(userId, step, at);
233
+ return true;
234
+ }
235
+ ```
236
+
237
+ | Call | Answers |
238
+ |---|---|
239
+ | `enrolTotp({ issuer, account, secret? })` | `{ secret, uri, digits, periodSeconds }` — `secret` omitted mints one |
240
+ | `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
241
+ | `createTotpReplayGuard(drift?)` | the in-process `{ isUsed, remember }`; a fleet passes a Redis-backed pair of the same two methods |
242
+ | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
243
+ | `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
244
+ | `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code |
245
+
246
+ `TOTP_DIGITS` (6), `TOTP_STEP_SECONDS` (30) and `TOTP_DRIFT_STEPS` (±1 window) are exported so an
247
+ app's own copy of the parameters cannot disagree with the verifier's.
248
+
249
+ A step is remembered per **subject**, not globally: a code is valid for `drift` windows either
250
+ side of now, so without the guard the same six digits log in twice inside a minute. `verifyTotp`
251
+ answers `{ ok: false, step }` — the step still named — when `usedSteps` already holds it, which is
252
+ how a replay is told apart from a wrong code.
253
+
254
+ **The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
255
+ throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
256
+ `createSession({ mfaSatisfied: true })` in the app's own route. The framework ships no
257
+ `POST /auth/mfa/verify`, deliberately — see [`CLAUDE.md`](CLAUDE.md) for the three things that
258
+ would have to land together, and why fewer is worse than none.
259
+
260
+ ## Email verification and password reset
261
+
262
+ One issue/consume pair, two purposes. The token is mailed and only its `sha256` is stored, and
263
+ consuming it is a single conditional statement — the hash is an **argument to** the consume, never
264
+ a comparison after one.
265
+
266
+ ```ts
267
+ import {
268
+ consumeVerification,
269
+ issueVerification,
270
+ type MailSender,
271
+ MemoryAdapter,
272
+ type VerificationRuntime,
273
+ } from '@ultimat3/auth';
274
+ import { systemClock } from '@ultimat3/core';
275
+
276
+ declare const mail: MailSender; // the app wires @ultimat3/mail's `send` here
277
+
278
+ const runtime: VerificationRuntime = { store: new MemoryAdapter(), clock: systemClock, mail };
279
+
280
+ const issued = await issueVerification(runtime, {
281
+ purpose: 'password-reset',
282
+ identifier: 'ada@example.com', // the address; also the store key
283
+ locale: 'en',
284
+ link: (token) => `https://acme.test/reset?token=${token}`,
285
+ });
286
+
287
+ const verification = await consumeVerification(runtime, {
288
+ purpose: 'password-reset',
289
+ identifier: 'ada@example.com',
290
+ token: issued.token, // in production this arrives off the link
291
+ });
292
+ ```
293
+
294
+ | Name | Is |
295
+ |---|---|
296
+ | `VERIFICATION_PURPOSES` | `['email-verify', 'password-reset']` — the whole set, and `VerificationPurpose` is derived from it |
297
+ | `VERIFICATION_TEMPLATES` | purpose → **catalog key** (`auth.email-verify`, `auth.password-reset`), never copy: the body lives in the app's i18n catalog |
298
+ | `DEFAULT_VERIFICATION_TTL_MS` | 24 h for `email-verify`, 1 h for `password-reset` — a reset link is a password |
299
+ | `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 |
300
+ | `issueVerification(runtime, input)` | `{ token, expiresAt }`. The token comes back for a test or a CLI; production only ever mails it |
301
+ | `consumeVerification(runtime, input)` | the `AuthVerification` row, or `X_UNAUTHENTICATED`. Unknown, spent, expired and mismatched are one answer |
302
+
303
+ One live token per `(purpose, identifier)`. A wrong guess destroys nothing: the store's
304
+ `takeVerification(purpose, identifier, tokenHash)` matches before it consumes, so an
305
+ unauthenticated POST with any token cannot kill the victim's live link.
306
+
307
+ ## Revocation, offboarding and access review
308
+
309
+ | Call | Blast radius |
310
+ |---|---|
311
+ | `revokeSession(runtime, id)` | one session |
312
+ | `revokeOtherSessions(runtime, userId, keep)` | every session but the caller's |
313
+ | `revokeUserSessions(auth, userId, reason)` | one person, their current session included |
314
+ | `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
315
+ | `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
316
+ | `disableUser(auth, userId, reason)` | stamps `disabledAt` **and** kills the sessions |
317
+ | `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries |
318
+ | `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires |
319
+
320
+ `reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
321
+ an incident review asks who killed these sessions and why, and a `delete` with no line answers
322
+ neither. Each one logs `auth.revocation` before it runs.
323
+
324
+ `deleteSessionsForOrg` joins through `x_users` and `x_sessions` deliberately does **not** gain an
325
+ `org_id`: a denormalised copy of the membership goes stale the moment somebody moves org, and a
326
+ stale row means the sweep leaves live exactly the sessions it was run to kill.
327
+
328
+ `describeUser` is an allow-list, never a delete-list — a column added to `AuthUser` later must not
329
+ appear in an admin response by default. No password hash, no TOTP secret, no recovery-code hash.
29
330
 
30
331
  ## Adapter seam
31
332
 
@@ -38,9 +339,46 @@ an adapter implementation, not a dependency of this package.
38
339
  | `MemoryAdapter` | `x new` before a database exists, and every test in this package |
39
340
  | your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
40
341
 
41
- ```bash
42
- x db gen "auth tables" # emits AUTH_TABLES into a migration
43
- ```
342
+ **An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
343
+ is a plain case-sensitive `text ... unique`, so an adapter that lowercased found accounts Postgres
344
+ would not. Normalisation happens once, above the seam, in `normaliseEmail` (`email.ts`): trim and
345
+ lowercase, nothing else. Call it before `findUserByEmail`/`createUser` in any login route of your
346
+ own, and key any bucket of your own with it — `accountKey` does.
347
+
348
+ The seam's newer members — `findUserByExternalId`, `listUsersByOrg`, `deleteSessionsForUser`,
349
+ `deleteSessionsForOrg`, `deleteSessionsCreatedBefore` — are **optional**, so a 1.2-era adapter
350
+ still satisfies the interface. Calling one an adapter has not implemented is `X_NOT_IMPLEMENTED`
351
+ with the method named, not a silent no-op.
352
+
353
+ `x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
354
+ `org_id` index. `X_USERS_MIGRATION_1_3` is those statements for an app already on 1.2; both
355
+ columns are additive with a default, so the migration takes no table rewrite.
356
+
357
+ **Nothing wires those statements into a migration for you, `As of 2026-08`.** `AUTH_TABLES` is the
358
+ DDL as plain strings (`AUTH_TABLE_NAMES` is what they create, and `X_USERS_TABLE`,
359
+ `X_SESSIONS_TABLE`, `X_ACCOUNTS_TABLE`, `X_API_KEYS_TABLE`, `X_VERIFICATIONS_TABLE` are the
360
+ individual ones). `x db gen <name>` diffs `describeEntities()` against the newest migration's
361
+ snapshot — these tables are not `entity()` declarations, so nothing outside `tables.ts` reads the
362
+ constant and the command cannot see them. Paste each statement into its own file under
363
+ `packages/db/migrations/`, one statement per migration, then `x db migrate`.
364
+
365
+ ## Sessions are not a write path
366
+
367
+ `verifySession` used to `UPDATE x_sessions … RETURNING *` on **every** authenticated request: one
368
+ request was a SELECT, a write and a second SELECT before the app's own first query. At 20k rps
369
+ that is 20k writes a second on one hot table, autovacuum falls behind, and the incident reads as
370
+ "the database is slow".
371
+
372
+ `SessionPolicy.idleSlideMs` — defaulting to `idleTtlMs / 20` — is how far `lastSeenAt` may drift
373
+ before a request writes it forward. A second request inside that window issues no write at all. A
374
+ changed IP or user agent is still written immediately, because that is the row a device list and
375
+ an incident review read. The trade is bounded and explicit: idle expiry is now precise to within
376
+ one `idleSlideMs`.
377
+
378
+ **Revocation is unaffected, and deliberately so.** `authenticate` re-reads the *user* row on every
379
+ request, so a revoked role takes effect on the very next one with no token-expiry lag — a better
380
+ property than any claims-in-a-JWT design. Throttling the session write is safe; caching the user
381
+ row would not be, and nothing does.
44
382
 
45
383
  ## Cookie
46
384
 
@@ -54,38 +392,78 @@ x db gen "auth tables" # emits AUTH_TABLES into a migration
54
392
  | `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
55
393
  | `Max-Age` | a client keeping it past the server's absolute ceiling |
56
394
 
57
- ## OAuth
395
+ ## OAuth — log in with GitHub
58
396
 
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.
397
+ `oauthLogin(auth)` **is** the flow. Two route descriptors, mounted at two fixed paths, composing
398
+ `beginOAuth`, the handshake cookie and `completeOAuthLogin`. Provider configs stay pure data —
399
+ importing `oauth.ts` performs no network I/O and reads no env.
61
400
 
62
401
  ```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
- }
402
+ const { start, callback } = oauthLogin(auth);
403
+
404
+ Bun.serve({
405
+ fetch(request) {
406
+ const { pathname } = new URL(request.url);
407
+ if (pathname.endsWith('/callback')) return callback.handle(request);
408
+ if (pathname.startsWith('/auth/oauth/')) return start.handle(request);
409
+ return new Response(null, { status: 404 });
410
+ },
411
+ });
71
412
  ```
72
413
 
414
+ | | `start` | `callback` |
415
+ |---|---|---|
416
+ | path | `/auth/oauth/:provider` | `/auth/oauth/:provider/callback` |
417
+ | success | `302` to the provider, `Set-Cookie: __Host-x_oauth_<provider>` | `303` to `successPath`, `Set-Cookie: __Host-x_session` **and** the handshake cleared |
418
+ | failure | the coded JSON body, status per code | the same, handshake cleared either way |
419
+
420
+ A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
421
+ is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
422
+ a rendered page. A bare `Request` in, a `Response` out: drivable from a test, mountable by any
423
+ router that can match a `:param`.
424
+
425
+ **The `Bun.serve` above is library usage, not app usage.** An Ultimate app's server is `runRole`
426
+ (`apps/web/server.ts` is three lines that call it), and `As of 2026-08` `ServeOptions` has no
427
+ routes seam — the route list is built inside `serveApp` and closed. So a second `Bun.serve` in an
428
+ app does not extend that server, it stands beside it: on its own socket, outside the pipeline, and
429
+ therefore outside `configureAuthenticator`, the rate limiter, the security headers and the
430
+ SIGTERM drain. A login flow is the last surface that should be the one running unthrottled and
431
+ unheadered.
432
+
433
+ Until the seam exists, an app serving these descriptors serves them itself and pays for all of
434
+ that itself — a second port to publish and health-check, its own throttle in front of `callback`,
435
+ its own security headers, and a drain that does not strand a handshake mid-flight. There is no
436
+ mounting API to call today; do not write one, and do not read this section as promising one.
437
+
438
+ **The paths are not configurable.** `X_OAUTH_STATE_INVALID` has always told the caller to restart
439
+ at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
440
+ reads. A movable base path is that sentence going stale again.
441
+
442
+ **Failure is JSON, not a redirect carrying `?error=`.** The callback is the one request whose
443
+ failure a developer must read, and there is no `?next=` on the success hop either: an
444
+ attacker-supplied return target on the endpoint that hands out a session is the classic open
445
+ redirect, and `nextAfterSignIn` in `@ultimat3/http` is the one implementation of that check.
446
+
447
+ `beginOAuth` / `handshakeCookie` / `completeOAuthLogin` stay exported for a flow that needs the
448
+ seams — but they are the seams, not the path.
449
+
450
+ ### Account linking
451
+
73
452
  ```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
- }
453
+ defineAuth({ adapter, providers: ['github'], link: 'verified-email' }) // the default
87
454
  ```
88
455
 
456
+ | `link` | a provider identity becomes an **existing** user when |
457
+ |---|---|
458
+ | `'verified-email'` (default) | the provider asserted the address verified **and** that account had verified it too |
459
+ | `'never'` | never — a collision is `X_UNAUTHENTICATED` and the caller uses their own credentials |
460
+
461
+ There is deliberately **no third value**. "Link on whatever address the provider sent" is not
462
+ spelled here at all: a provider that does not verify addresses turns it into account takeover —
463
+ register the victim's address there, press the button, inherit the account. Unrepresentable beats
464
+ explicit, the same way `PkcePair.method` is the literal `'S256'` and never `'plain'`. An app that
465
+ truly wants something looser wraps `signInWithOAuth` and resolves the user itself.
466
+
89
467
  The handshake carries `state`, `nonce` and the PKCE verifier across two requests, so it needs a
90
468
  home. `handshakeCookie` is that home — sealed with `SESSION_SECRET`, `HttpOnly; Secure;
91
469
  SameSite=Lax` under a `__Host-` name, and expired against the server's clock rather than the
@@ -119,6 +497,7 @@ JWT signed with the `.p8` key, which Apple expires every six months.
119
497
 
120
498
  | Step | Does | Fails with |
121
499
  |---|---|---|
500
+ | `oauthLogin(auth)` | the two routes: redirect out, session back | `X_OAUTH_PROVIDER_UNKNOWN`, `X_OAUTH_DENIED` |
122
501
  | `handshakeCookie` / `readHandshakeCookie` | seals the handshake onto the redirect, opens it on the callback | `X_OAUTH_STATE_INVALID`, `X_ENV_MISSING` |
123
502
  | `exchangeOAuthCode` | POSTs the code + PKCE verifier, verifies the id token | `X_OAUTH_EXCHANGE_FAILED`, `X_OAUTH_TOKEN_INVALID` |
124
503
  | `oauthProfile` | id-token claims, else userinfo → one normalised identity | `X_OAUTH_EXCHANGE_FAILED` |
@@ -130,8 +509,8 @@ JWT signed with the `.p8` key, which Apple expires every six months.
130
509
  token, because that is where the code flow actually carries it.
131
510
  - GitHub reports a bad, reused or expired code as **HTTP 200 with an `error` field**. Trusting
132
511
  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.
512
+ - An address is only linked to an existing account when **both** sides verified it (`link:
513
+ 'verified-email'`). Otherwise whoever registered the address first inherits the login.
135
514
 
136
515
  ## API keys — how an agent authenticates
137
516
 
@@ -155,12 +534,15 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
155
534
  | `X_MFA_REQUIRED` | password proven, second factor outstanding |
156
535
  | `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
157
536
  | `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 |
537
+ | `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` |
538
+ | `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 |
539
+ | `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
540
+ | `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
159
541
  | `X_PASSWORD_WEAK` | strength check rejected the password |
160
- | `X_ACCOUNT_LOCKED` | per-ip or per-account bucket is inside its lockout |
542
+ | `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout |
161
543
  | `X_API_KEY_INVALID` | key unknown, revoked, expired or wrong |
162
544
  | `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 |
545
+ | `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
546
 
165
547
  ```bash
166
548
  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": "2.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": "2.0.0",
35
+ "@ultimat3/db": "2.0.0",
36
+ "@ultimat3/schema": "2.0.0"
36
37
  }
37
38
  }