@spfn/auth 0.3.0-beta.2 → 0.3.0-beta.21

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.
Files changed (47) hide show
  1. package/README.md +1488 -25
  2. package/dist/client-proof.d.ts +45 -15
  3. package/dist/client-proof.js +201 -5
  4. package/dist/client-proof.js.map +1 -1
  5. package/dist/client.d.ts +92 -1
  6. package/dist/client.js +58 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.d.ts +342 -0
  9. package/dist/config.js +153 -4
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +396 -3
  12. package/dist/errors.js +257 -2
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +202 -2
  15. package/dist/index.js +277 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/machine-principals-B7N8gux0.d.ts +3087 -0
  18. package/dist/nextjs/api.js +350 -12
  19. package/dist/nextjs/api.js.map +1 -1
  20. package/dist/nextjs/client.d.ts +28 -1
  21. package/dist/nextjs/client.js +24 -3
  22. package/dist/nextjs/client.js.map +1 -1
  23. package/dist/nextjs/server.d.ts +173 -3
  24. package/dist/nextjs/server.js +372 -10
  25. package/dist/nextjs/server.js.map +1 -1
  26. package/dist/server.d.ts +4167 -582
  27. package/dist/server.js +9618 -4341
  28. package/dist/server.js.map +1 -1
  29. package/dist/{session-DTHahDQ9.d.ts → session-Dfwu5g2W.d.ts} +28 -1
  30. package/migrations/20260810112144_colorful_tomorrow_man/migration.sql +18 -0
  31. package/migrations/20260810112144_colorful_tomorrow_man/snapshot.json +3576 -0
  32. package/migrations/20260901091716_fine_arclight/migration.sql +21 -0
  33. package/migrations/20260901091716_fine_arclight/snapshot.json +3849 -0
  34. package/migrations/20260906155957_natural_moonstone/migration.sql +33 -0
  35. package/migrations/20260906155957_natural_moonstone/snapshot.json +4275 -0
  36. package/migrations/20260907020904_giant_eternals/migration.sql +21 -0
  37. package/migrations/20260907020904_giant_eternals/snapshot.json +4561 -0
  38. package/migrations/20260907044807_eminent_angel/migration.sql +2 -0
  39. package/migrations/20260907044807_eminent_angel/snapshot.json +4561 -0
  40. package/migrations/20260918083158_foamy_roughhouse/migration.sql +55 -0
  41. package/migrations/20260918083158_foamy_roughhouse/snapshot.json +5271 -0
  42. package/migrations/20260918142743_smart_cassandra_nova/migration.sql +2 -0
  43. package/migrations/20260918142743_smart_cassandra_nova/snapshot.json +5297 -0
  44. package/migrations/20260918143800_fat_princess_powerful/migration.sql +17 -0
  45. package/migrations/20260918143800_fat_princess_powerful/snapshot.json +5523 -0
  46. package/package.json +9 -6
  47. package/dist/authenticate-55LeXHqZ.d.ts +0 -1447
package/README.md CHANGED
@@ -21,7 +21,7 @@ Underneath: asymmetric client-signed JWTs (ES256/RS256), OTP verification, OAuth
21
21
  through a pluggable provider registry (Google, GitHub, Kakao and Naver built in), session
22
22
  cookies for Next.js, and runtime RBAC. Routes mount under `/_auth/*` and are reached
23
23
  through a typed `authApi` client. Requires `@spfn/core`; Next.js is an optional peer
24
- (`^16.2.11`).
24
+ (`^16.3.3`).
25
25
 
26
26
  ## Install
27
27
 
@@ -29,6 +29,11 @@ through a typed `authApi` client. Requires `@spfn/core`; Next.js is an optional
29
29
  pnpm add @spfn/auth drizzle-orm@1.0.0-rc.4
30
30
  ```
31
31
 
32
+ `@simplewebauthn/server` and `@simplewebauthn/browser` come along as dependencies —
33
+ [passkeys](#passkeys-webauthn) need them, and standards conformance is the whole risk there.
34
+ The browser half is bundled into the `./client` entry rather than marked external, so nothing
35
+ in your app has to know about it.
36
+
32
37
  ## Import paths
33
38
 
34
39
  Entry points (from `package.json` `exports`). Picking the wrong one breaks the build —
@@ -78,10 +83,10 @@ applied globally via `.use()`. Public routes opt out per-route with `.skip(['aut
78
83
  ```typescript
79
84
  import { defineRouter } from '@spfn/core/route';
80
85
  import { authRouter, authenticate } from '@spfn/auth/server';
81
- import { getHealth } from './routes/health';
86
+ import { getStatus } from './routes/status';
82
87
 
83
88
  export const appRouter = defineRouter({
84
- getHealth,
89
+ getStatus,
85
90
  // ...your routes
86
91
  })
87
92
  .packages([authRouter]) // mounts /_auth/* and exposes routes on authApi
@@ -136,17 +141,19 @@ real secret values out of band, never commit them.
136
141
  | `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
137
142
  | `SPFN_AUTH_BCRYPT_SALT_ROUNDS` | `.env.server` | — | default `12` (native bcrypt, off the event loop) |
138
143
  | `SPFN_AUTH_COOKIE_SECURE` | both | — | override Secure flag (defaults to `NODE_ENV==='production'`) |
144
+ | `SPFN_AUTH_CSRF` | `.env.local` | — | `off` \| `warn` \| `enforce`; unset behaves as `warn` — see [CSRF protection](#csrf-protection) |
139
145
  | `SPFN_AUTH_ADMIN_*` | `.env.server` | — | admin seeding (see below) |
140
146
  | `SPFN_AUTH_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | enables Google OAuth when both set |
141
147
  | `SPFN_AUTH_GOOGLE_SCOPES` | `.env.server` | — | comma-separated; default `email,profile` |
142
- | `SPFN_AUTH_GOOGLE_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/google/callback` — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
148
+ | `SPFN_AUTH_GOOGLE_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/google/callback`; an override must stay on the web app origin at that path and is **checked at boot** — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
143
149
  | `SPFN_AUTH_KAKAO_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | REST API key enables Kakao Login; secret is included when configured |
144
150
  | `SPFN_AUTH_KAKAO_ADMIN_KEY` | `.env.server` | — | app admin key; required to verify the Kakao User Unlinked webhook |
145
- | `SPFN_AUTH_KAKAO_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scope `account_email`; callback `/_auth/oauth/kakao/callback` |
151
+ | `SPFN_AUTH_KAKAO_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scope `account_email`; callback `/_auth/oauth/kakao/callback` on the web app origin, **checked at boot** — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
146
152
  | `SPFN_AUTH_NAVER_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable Naver Login |
147
- | `SPFN_AUTH_NAVER_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/naver/callback` |
153
+ | `SPFN_AUTH_NAVER_REDIRECT_URI` | `.env.server` | — | default `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}/_auth/oauth/naver/callback`; an override must stay on the web app origin at that path and is **checked at boot** — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
148
154
  | `SPFN_AUTH_GITHUB_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | both values enable GitHub OAuth |
149
- | `SPFN_AUTH_GITHUB_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scopes `read:user,user:email`; callback `/_auth/oauth/github/callback` |
155
+ | `SPFN_AUTH_GITHUB_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scopes `read:user,user:email`; callback `/_auth/oauth/github/callback` on the web app origin, **checked at boot** — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
156
+ | `SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK` | `.env.server` | — | `off` disables the boot check of the four `_REDIRECT_URI` overrides; any other value (unset included) runs it — see [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite) |
150
157
  | `SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated client IDs accepted as native id_token audience (iOS/Android/web); enables Google native sign-in |
151
158
  | `SPFN_AUTH_APPLE_CLIENT_IDS` | `.env.server` | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
152
159
  | `SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated Kakao app keys accepted as native id_token audience (native app key); `SPFN_AUTH_KAKAO_CLIENT_ID` is also accepted, so either one enables Kakao native sign-in |
@@ -154,6 +161,16 @@ real secret values out of band, never commit them.
154
161
  | `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | — | default `/auth/callback` |
155
162
  | `SPFN_AUTH_OAUTH_ERROR_URL` | `.env.server` | — | default `/auth/error?error={error}` |
156
163
  | `SPFN_AUTH_RESERVED_USERNAMES` / `_USERNAME_MIN_LENGTH` / `_USERNAME_MAX_LENGTH` | `.env.server` | — | username rules |
164
+ | `SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES` / `_SETUP_TTL_MINUTES` | `.env.server` | — | defaults `30` / `15` — see [Verified-email signup](#verified-email-signup) |
165
+ | `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `.env.server` | — | default `/signup/confirm`; the page in your app the emailed link opens |
166
+ | `SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES` / `_SETUP_TTL_MINUTES` | `.env.server` | — | defaults `30` / `15` — see [Password reset](#password-reset-verified-email) |
167
+ | `SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH` | `.env.server` | — | default `/password/reset`; the page in your app the emailed link opens |
168
+ | `SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES` | `.env.server` | — | default `30` — see [The sign-out-everywhere link](#the-sign-out-everywhere-link) |
169
+ | `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` | `.env.server` | — | default `/account/revoke-all`; the page in your app the link opens |
170
+ | `SPFN_AUTH_LINK_MAIL_DELIVERY` | `.env.server` | — | `auto` (default) \| `inline` \| `queued`; who sends signup-link, reset and account-exists mail — see [Link mail delivery](#link-mail-delivery) |
171
+ | `SPFN_AUTH_PASSKEY_RP_ID` / `_RP_NAME` / `_ORIGINS` | `.env.server` | — | relying party for passkeys; defaults derive from `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}` and are **checked at boot** — see [Passkeys](#passkeys-webauthn) |
172
+ | `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | — | `preferred` (default) or `required`; `discouraged` refuses boot |
173
+ | `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` / `_RECENT_AUTH_MINUTES` | `.env.server` | — | defaults `300` / `10` — see [Passkeys](#passkeys-webauthn) |
157
174
  | `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
158
175
 
159
176
  Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
@@ -178,7 +195,25 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
178
195
  | `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
179
196
  | `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
180
197
  | `register` | POST `/_auth/register` | public | create user + register public key |
198
+ | `requestSignupLink` | POST `/_auth/signup/email` | public | email a one-time signup confirmation link — see [Verified-email signup](#verified-email-signup) |
199
+ | `confirmSignupLink` | POST `/_auth/signup/email/confirm` | public | exchange the link for a password-setup session |
200
+ | `completeSignup` | POST `/_auth/signup/password` | setup session | set the password, which creates the account and signs in |
201
+ | `requestPasswordReset` | POST `/_auth/password/reset` | public | email a one-time password reset link — see [Password reset](#password-reset-verified-email) |
202
+ | `confirmPasswordReset` | POST `/_auth/password/reset/confirm` | public | exchange the link for a password-setup session |
203
+ | `completePasswordReset` | POST `/_auth/password/reset/complete` | setup session | set the new password, sign every other device out, sign this one in |
181
204
  | `login` | POST `/_auth/login` | public | password login + new session key |
205
+ | `startDeviceAuth` | POST `/_auth/device/start` | public | begin a device-code login — see [Device-code login](#device-code-login) |
206
+ | `pollDeviceAuth` | POST `/_auth/device/poll` | public | ask whether the request was answered; the approved answer *is* the login |
207
+ | `getDeviceAuthInfo` | POST `/_auth/device/info` | yes | what device is asking, so the approval screen can show it |
208
+ | `approveDeviceAuth` | POST `/_auth/device/approve` | yes | let the waiting device in |
209
+ | `denyDeviceAuth` | POST `/_auth/device/deny` | yes | refuse it |
210
+ | `passkeyRegisterOptions` | POST `/_auth/passkeys/register/options` | yes | begin enrolling a passkey — see [Passkeys](#passkeys-webauthn) |
211
+ | `passkeyRegisterVerify` | POST `/_auth/passkeys/register/verify` | yes | verify the attestation and keep the credential |
212
+ | `passkeyLoginOptions` | POST `/_auth/passkeys/login/options` | public | begin a passkey sign-in; takes no identifier |
213
+ | `passkeyLoginVerify` | POST `/_auth/passkeys/login/verify` | public | verify the assertion; answers exactly as `login` |
214
+ | `listPasskeys` | POST `/_auth/passkeys/list` | yes | the caller's enrolled passkeys |
215
+ | `renamePasskey` | POST `/_auth/passkeys/rename` | yes | rename one |
216
+ | `revokePasskey` | POST `/_auth/passkeys/revoke` | yes | retire one (refused if it is the last way in) |
182
217
  | `logout` | POST `/_auth/logout` | yes | revoke current key |
183
218
  | `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
184
219
  | `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
@@ -194,6 +229,7 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
194
229
  | `cancelAccountDeletion` | POST `/_auth/deletion/cancel` | public | cancel a pending deletion (credential-based recovery) |
195
230
  | `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
196
231
  | OAuth routes | — | — | see OAuth section |
232
+ | `registerOAuth2Client` / `getOAuth2Authorize` / `createOAuth2AuthorizationCode` / `oauth2Token` / `oauth2Revoke` / `listOAuth2Grants` / `revokeOAuth2Grant` | `/_auth/oauth2/*` | mixed | OAuth 2.1 authorization server for MCP clients — see [Authorization server for MCP clients](#authorization-server-for-mcp-clients). 404 unless configured |
197
233
 
198
234
  There is deliberately **no account-existence endpoint**. `POST /_auth/exists` was removed
199
235
  because it answered "does this account exist" directly, which is user enumeration; the
@@ -205,8 +241,280 @@ sends the public key on register/login, signs request JWTs locally, and the serv
205
241
  with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
206
242
  Keys expire after 90 days — rotate with `rotateKey`.
207
243
 
244
+ ### Verified-email signup
245
+
246
+ A second way in, alongside the six-digit code. The address is proven before a password
247
+ exists, so nothing is stored for someone who never confirms.
248
+
249
+ ```
250
+ request → a one-time link is emailed
251
+ confirm → the link becomes a short-lived, HttpOnly password-setup session
252
+ password → the account is created, the device registered, the user signed in
253
+ ```
254
+
255
+ The six-digit-code path (`sendVerificationCode` → `verifyCode` → `register`) is unchanged.
256
+ Offer whichever suits your product, or both.
257
+
258
+ **1 — request the link.** The response is identical whether or not the address already has
259
+ an account, so it cannot be used to probe for accounts. When one exists, the owner gets a
260
+ "you already have an account" notice instead of a usable link.
261
+
262
+ ```typescript
263
+ await authApi.requestSignupLink.call({
264
+ body: { email: 'user@example.com', returnPath: '/welcome' }, // returnPath optional
265
+ });
266
+ // → { success: true, expiresAt }
267
+ ```
268
+
269
+ Calling it again is how a resend works: it invalidates the previous link and any setup
270
+ session opened from it. `returnPath` must be a path inside your app — absolute URLs,
271
+ `//host`, and `..` are refused, so the link cannot become an open redirect.
272
+
273
+ The mail leaves through the `auth.link-mail` job when pg-boss is initialised — register
274
+ `authJobRouter` — so neither branch of this endpoint waits on a mail provider; see
275
+ [Link mail delivery](#link-mail-delivery).
276
+
277
+ **2 — the page the link opens.** The email points at a page in *your* app
278
+ (`SPFN_AUTH_SIGNUP_CONFIRM_PATH`, default `/signup/confirm`), not at an API route. That page
279
+ reads the token from the query string and posts it:
280
+
281
+ ```typescript
282
+ 'use client';
283
+
284
+ const token = useSearchParams().get('token');
285
+
286
+ const { email, returnPath } = await authApi.confirmSignupLink.call({ body: { token } });
287
+
288
+ // Drop the token from the URL so it does not linger in history or a Referer header.
289
+ window.history.replaceState({}, '', window.location.pathname);
290
+ ```
291
+
292
+ The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
293
+ and strips it from the response body, so page script never holds it. Serve this page with
294
+ `Referrer-Policy: no-referrer`.
295
+
296
+ **3 — set the password.** This is the step that creates the account. The setup cookie
297
+ authorizes it; the device keypair is injected by the interceptor exactly as it is for
298
+ `register`.
299
+
300
+ ```typescript
301
+ await authApi.completeSignup.call({ body: { password } });
302
+ // → { userId, publicId, email } + session cookie, same as register
303
+ ```
304
+
305
+ Creating the user, registering the device key, and marking the setup session used all commit
306
+ together. A password that fails the strength policy leaves the session usable, so the user
307
+ retypes rather than requesting a fresh email.
308
+
309
+ **Settings.**
310
+
311
+ | Variable | Default | Meaning |
312
+ |----------|---------|---------|
313
+ | `SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
314
+ | `SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
315
+ | `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `/signup/confirm` | the page in your app the link opens |
316
+
317
+ The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
318
+ OAuth callbacks use. Delivery uses the `signup-link` template in `@spfn/notification` —
319
+ override it there to change the copy.
320
+
321
+ **What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
322
+ `spfn_auth.signup_link_tokens`. Neither credential is recoverable from the database, and
323
+ both are one-time: a link opens one setup session, and a setup session sets one password.
324
+
325
+ ### Password reset (verified email)
326
+
327
+ The way back into an account whose password is gone, using the address the account already
328
+ proved. Same three steps as the signup above, and the same posture on the two credentials.
329
+
330
+ ```
331
+ request → a one-time link is emailed
332
+ confirm → the link becomes a short-lived, HttpOnly password-setup session
333
+ complete → the new password is written, every other device is signed out, this one is signed in
334
+ ```
335
+
336
+ **Who can reset.** An `active` account whose `emailVerifiedAt` is set **or** that already has
337
+ a password. The second half is what makes the rule work on accounts created before the
338
+ column was stamped: both register paths proved the address at signup. An OAuth-only account
339
+ whose provider reported the address unverified has neither and is excluded — for it, a reset
340
+ would be a way in built on an address nobody proved.
341
+
342
+ **1 — request the link.** The response is identical for every input — same status, same two
343
+ fields, same `expiresAt` arithmetic — and mail goes only to an account that can be reset, so
344
+ neither the answer nor the mailbox reveals whether an address has an account here.
345
+
346
+ ```typescript
347
+ await authApi.requestPasswordReset.call({
348
+ body: { email: 'user@example.com', returnPath: '/account' }, // returnPath optional
349
+ });
350
+ // → { success: true, expiresAt }
351
+ ```
352
+
353
+ Calling it again is how a resend works: it invalidates the previous link and any setup
354
+ session opened from it. `returnPath` must be a path inside your app — absolute URLs,
355
+ `//host`, and `..` are refused, so the link cannot become an open redirect.
356
+
357
+ The mail leaves through the `auth.link-mail` job when pg-boss is initialised — register
358
+ `authJobRouter` — so an address with an account and one without cost the same; see
359
+ [Link mail delivery](#link-mail-delivery).
360
+
361
+ **2 — the page the link opens.** The email points at a page in *your* app
362
+ (`SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH`, default `/password/reset`), not at an API route.
363
+ That page reads the token from the query string and posts it:
364
+
365
+ ```typescript
366
+ 'use client';
367
+
368
+ const token = useSearchParams().get('token');
369
+
370
+ const { email, returnPath } = await authApi.confirmPasswordReset.call({ body: { token } });
371
+
372
+ // Drop the token from the URL so it does not linger in history or a Referer header.
373
+ window.history.replaceState({}, '', window.location.pathname);
374
+ ```
375
+
376
+ The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
377
+ and strips it from the response body, so page script never holds it. It is a cookie of its
378
+ own, not the signup one, so neither secret is ever accepted by the other flow. Serve this
379
+ page with `Referrer-Policy: no-referrer`.
380
+
381
+ **3 — set the new password.** The setup cookie authorizes it; the device keypair is injected
382
+ by the interceptor exactly as it is for `login`.
383
+
384
+ ```typescript
385
+ await authApi.completePasswordReset.call({ body: { password } });
386
+ // → { userId, publicId, email } + session cookie, same as login
387
+ ```
388
+
389
+ **Every other device is signed out.** Completing a reset denies every pending device
390
+ authorization and revokes every active key, exactly as `changePassword` does — whoever was
391
+ signed in on the old password, including the person the reset was needed for, has to sign in
392
+ again. The browser that performed the reset is signed in on a fresh key registered after the
393
+ revocation, so it does not have to retype the new password. `emailVerifiedAt` is stamped if
394
+ it was not already, `passwordChangeRequired` is cleared, and `auth.password.reset` is emitted
395
+ after commit.
396
+
397
+ The new hash, the revocations, the new device key and the completion mark commit together. A
398
+ password that fails the strength policy leaves the session usable, so the user retypes rather
399
+ than requesting a fresh email.
400
+
401
+ **Settings.**
402
+
403
+ | Variable | Default | Meaning |
404
+ |----------|---------|---------|
405
+ | `SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
406
+ | `SPFN_AUTH_PASSWORD_RESET_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
407
+ | `SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH` | `/password/reset` | the page in your app the link opens |
408
+
409
+ The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
410
+ signup link uses. Delivery uses the `password-reset` template in `@spfn/notification` —
411
+ override it there to change the copy.
412
+
413
+ **What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
414
+ `spfn_auth.password_reset_tokens`. Neither credential is recoverable from the database, and
415
+ both are one-time: a link opens one setup session, and a setup session sets one password.
416
+ A separate table from `signup_link_tokens`, so a signup secret can never address a reset row.
417
+
418
+ ### Device-code login
419
+
420
+ A way in for a device that has a screen but no comfortable keyboard — a TV, a console, a CLI
421
+ on a headless box. The new device shows a short code; the account owner types that code on a
422
+ device that is already signed in.
423
+
424
+ ```typescript
425
+ // On the new device — it has no key on file, so this call is public.
426
+ const { deviceCode, userCode, expiresAtMillis, intervalMillis } =
427
+ await authApi.startDeviceAuth.call({ body: {
428
+ publicKey, keyId, fingerprint, algorithm: 'ES256',
429
+ deviceName: 'Living room TV', platform: 'desktop',
430
+ } });
431
+
432
+ // Show `userCode` (XXXX-XXXX) on this device's screen, then poll every intervalMillis.
433
+ const answer = await authApi.pollDeviceAuth.call({ body: { deviceCode } });
434
+ // → { status: 'pending', intervalMillis }
435
+ // → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }
436
+ ```
437
+
438
+ ```typescript
439
+ // On the signed-in device — the user typed the code they read off the other screen.
440
+ const asking = await authApi.getDeviceAuthInfo.call({ body: { userCode } });
441
+ // → { deviceName?, platform?, fingerprintPrefix, requestedAtMillis, expiresAtMillis }
442
+
443
+ await authApi.approveDeviceAuth.call({ body: { userCode } }); // or denyDeviceAuth
444
+ ```
445
+
446
+ **There is no token handed over, because there is no token.** Every request in this system is
447
+ signed by the calling device's own key, so "logging a device in" means getting its public key
448
+ into `user_public_keys` under the right account — which is exactly what the winning poll does.
449
+ That is why the approved answer is the same shape `login` returns: from the client's side the
450
+ two ways in are indistinguishable.
451
+
452
+ - **Only ever show the code on the new device's screen.** The whole attack on this flow is
453
+ someone sending a victim a code and asking them to approve it — a support call, a chat
454
+ message, a "verify your account" email. A code that arrived any way other than off the
455
+ device in front of you is an attack. This is why `info` and `approve` answer with the
456
+ requesting device's name, platform and fingerprint prefix, and why an approval screen that
457
+ shows only the code is doing it wrong: it is asking the user to confirm a number they were
458
+ just told.
459
+ - **The device code is stored only as a SHA-256 hash**, like the ops-token and signup-link
460
+ secrets. It is returned once. A dump of `spfn_auth.device_authorizations` does not let its
461
+ reader finish anyone's login.
462
+ - **The user code is stored in the clear, and that is fine** — it authorizes nothing without
463
+ an approver who is already signed in. It is drawn from an alphabet with no `0`/`O` or
464
+ `1`/`I`/`L`, since it is read off one screen and typed on another.
465
+ - **A decision is made once.** Approve and deny move the record from `pending` and nowhere
466
+ else, so a second approval, a deny after an approve, or two approvals racing each other all
467
+ get `DeviceAuthAlreadyHandledError` (409) — a refusal is never undone.
468
+ - **The approval is one-shot.** The poll that registers the key spends the record in the same
469
+ statement that reads it, so of two polls arriving together exactly one registers the key and
470
+ the other is answered as if the code were unknown.
471
+ - **A spent code and a code that never existed answer identically** (`DeviceAuthNotFoundError`,
472
+ 404). Saying "that one was real, but it is used up" is the difference between guessing at
473
+ random and knowing a guess landed. Every route that accepts a code is rate limited for the
474
+ same reason: `start` and `poll` per IP, `info` / `approve` / `deny` per IP *and* per calling
475
+ account.
476
+ - **Expiry outranks state.** A code that sat past its TTL is expired whatever it says, so an
477
+ approval nobody collected in time registers nothing. The TTL travels in the statement that
478
+ moves the record, not only in the read before it, so a code cannot be spent by a poll that
479
+ read it a moment before it died.
480
+ - **A global revocation reaches the codes too.** `revoke-all`, a password change and a
481
+ deletion request each refuse the account's live device authorizations, so an approval nobody
482
+ collected cannot register a fresh key seconds after the user signed everything out — which
483
+ would hand one back to exactly the device they were cutting off. Revoking a single key,
484
+ logging out and rotating a key do not: those name one device, and the waiting one is not it.
485
+ - **The poll re-checks the account.** It is a login, so it refuses a suspended or
486
+ pending-deletion account with the same errors `/_auth/login` does. Approval and collection
487
+ are separate moments, and what the account is when the key is registered is what counts.
488
+ - **`start` bounds what it stores.** It is the one route that takes key material from a caller
489
+ who cannot authenticate, so `publicKey`, `keyId` and `fingerprint` carry length limits —
490
+ generous next to a real key (an RSA-2048 SPKI is 392 base64 characters against a 2048 limit)
491
+ and small next to the megabyte that would otherwise sit in a table no job clears.
492
+ - **Clock skew cannot affect this.** Every timestamp in the decision is the server's. The
493
+ `expiresAtMillis` in the start response is for the waiting device's countdown display, and
494
+ nothing the client believes about the time reaches the server's judgement.
495
+
496
+ Two knobs, both announced to the waiting device in the start response and therefore resolved
497
+ at lifecycle time rather than read per call:
498
+
499
+ ```typescript
500
+ createAuthLifecycle({
501
+ deviceAuth: {
502
+ ttlMs: 10 * 60 * 1000, // how long a code lives. default 10 minutes
503
+ intervalMs: 5000, // poll interval the server asks for. default 5s
504
+ },
505
+ })
506
+ ```
507
+
508
+ No job sweeps the table. Rows are judged by `expiresAt` whenever they are read or moved, so a
509
+ stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
510
+ not run out.
511
+
208
512
  ### Registered devices (key management)
209
513
 
514
+ A [passkey](#passkeys-webauthn) is **not** one of these keys: it is a credential that proves
515
+ identity at sign-in, after which an ordinary device key is registered exactly as a password
516
+ login registers one.
517
+
210
518
  Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
211
519
  `listKeys` / `revokeKey` / `revokeAllKeys` are what let the account owner see what accumulated and
212
520
  cut off anything they no longer recognise.
@@ -214,7 +522,8 @@ cut off anything they no longer recognise.
214
522
  ```typescript
215
523
  const { keys } = await authApi.listKeys.call({ body: {} });
216
524
  // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
217
- // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis? }]
525
+ // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis?,
526
+ // registeredIp?, registeredUserAgent? }]
218
527
 
219
528
  await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
220
529
  ```
@@ -236,13 +545,19 @@ await authApi.revokeAllKeys.call({ body: {} }); // other devices o
236
545
  await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
237
546
  ```
238
547
 
239
- > **All three are POST with their arguments in the body, deliberately.** The mobile auth
548
+ > **All three key-management operations are POST with their arguments in the body, deliberately.** The mobile auth
240
549
  > profile (clientProofV1) signs the request body, and `canonical-json` fixes exactly how those
241
550
  > bytes are written. A `GET` has no body to sign, and a value in the path has no such rule —
242
551
  > client and server could disagree on the signed string over percent-encoding, a trailing
243
552
  > slash, or a proxy rewrite alone, and the request would be refused with nothing in the logs
244
- > naming the cause. Every operation in the contract is shaped this way.
245
-
553
+ > naming the cause. Proof-bearing auth operations are shaped this way; the unproven,
554
+ > bodyless `core.time` synchronization prerequisite is the explicit exception.
555
+
556
+ - **A key must be the type its algorithm names.** A P-256 SPKI declared `RS256`, an RSA key
557
+ declared `ES256`, and a curve other than P-256 declared `ES256` are each refused 400 with
558
+ `KeyAlgorithmMismatchError` on register, login, rotate and device start — the algorithm is
559
+ stored beside the key and read back at proof verification, so a mismatch accepted at
560
+ enrollment would surface only once the device already believed it was enrolled.
246
561
  - **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
247
562
  The list exists to recognise a device and point at it; the full fingerprint is what a native
248
563
  sign-in sends as its nonce, not a label.
@@ -254,6 +569,10 @@ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // every
254
569
  - **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
255
570
  "sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
256
571
  only as a side effect of changing a password, which nobody does for that reason.
572
+ - **It also refuses device-code approvals still in flight**, in both modes, because an approved
573
+ code is a key that has not been handed out yet: the next poll would register a fresh active one
574
+ and undo the sign-out. `revokedCount` still counts keys only — a code nobody collected was
575
+ never a session. See [Device-code login](#device-code-login).
257
576
  - **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
258
577
  the answer is only ever "not yours" and reveals nothing about other accounts.
259
578
  - **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
@@ -266,6 +585,14 @@ Every path that registers a key (`register`, `login`, `rotateKey`, native OAuth)
266
585
  only — nothing is authorized by them — and both are absent on keys registered before they existed.
267
586
  Rotation carries the replaced key's label over unless the client sends a new one.
268
587
 
588
+ - **`registeredIp` and `registeredUserAgent` are where the device came from**, captured once from
589
+ the request that registered the key and never updated — a device that later signs requests from
590
+ another network still shows the address it appeared from, which is what makes an entry the owner
591
+ does not recognise recognisable. Both are absent when the request resolved neither and on keys
592
+ registered before the columns existed; the literal string `unknown` is never stored. They are
593
+ unauthenticated display material, spoofable on any request that does not come through a verified
594
+ proxy, so render them and decide nothing by them. Mobile contract 0.11.0.
595
+
269
596
  All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
270
597
  `auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
271
598
  rotation.
@@ -278,6 +605,355 @@ is still active is the one
278
605
  exception: it stays a no-op success, so repeated logins from the same device keep working, and an
279
606
  expired-but-active key has its expiry extended by the sign-in that proved the identity again.
280
607
 
608
+ ### The sign-out-everywhere link
609
+
610
+ The key operations above all need a session, which is exactly what an owner who no longer trusts
611
+ the device in front of them does not want to use. `createRevokeAllLink` mints a one-time link your
612
+ app mails to the address the account has already proved; opening it signs every device out with no
613
+ session at all.
614
+
615
+ ```typescript
616
+ import { createRevokeAllLink } from '@spfn/auth/server';
617
+
618
+ const { url, expiresAt } = await createRevokeAllLink(userId); // default TTL 30 minutes
619
+ const short = await createRevokeAllLink(userId, { ttlMinutes: 10 });
620
+ ```
621
+
622
+ **The link opens a page in your app** (`SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH`, default
623
+ `/account/revoke-all`), not an API route — the same shape the signup and reset links use. That page
624
+ reads the token out of the query string and makes two calls: one to render, one when the owner
625
+ presses the button.
626
+
627
+ ```typescript
628
+ 'use client';
629
+
630
+ const token = useSearchParams().get('token');
631
+
632
+ // Describing the link changes nothing at all, so a mail scanner that prefetches
633
+ // the page has not signed anybody out.
634
+ const { expiresAt, activeKeyCount } = await authApi.confirmRevokeAllLink.call({ body: { token } });
635
+
636
+ // The button.
637
+ const { revokedCount } = await authApi.consumeRevokeAllLink.call({ body: { token } });
638
+ ```
639
+
640
+ - **Every refusal is the same 404** (`RevokeAllLinkError`), with the same body: unknown, expired,
641
+ already spent, superseded by a newer link, issued against a key generation that has since moved,
642
+ or belonging to an account that is not active. Telling those apart would tell whoever holds a
643
+ random value that it named something real. 404 rather than the 401 the [password reset
644
+ link](#password-reset-verified-email) answers with, because there is no credential here to have
645
+ been wrong: the mailbox is the proof, and what arrives either names an outstanding link or names
646
+ nothing.
647
+ - **The token travels in the request body, never in a path segment.** The request logger records
648
+ the path of every request, and so does whatever proxy sits in front of it.
649
+ - **Your obligation, which this package cannot enforce:** the returned `url` carries the plaintext
650
+ token, because this flow sends no mail of its own. Do not log it, do not persist it, do not put
651
+ it in a job payload — hand it to the mail template and let it go. The package's other two links
652
+ are minted inside the worker that sends them precisely so no caller ever holds one; this one
653
+ cannot be.
654
+ - **It is one-time and generation-bound.** Consuming it is a single statement, so two clicks
655
+ produce one sign-out and one 404. It also dies the moment anything else ends the account's key
656
+ generation — a completed password reset, a password change, a deletion request, or the
657
+ `revokeAllKeys` route in either mode.
658
+ - **It does not change the password.** Send it alongside a password reset link: this one ends the
659
+ sessions, that one ends the credential that started them.
660
+ - **Issuing again supersedes.** A second link retires the first, so asking twice does not leave a
661
+ spare capability in the mailbox.
662
+ - **`ttlMinutes` must be a positive whole number.** Zero or negative is a `ValidationError` and
663
+ writes no row; an unknown `userId` is refused explicitly rather than surfacing as a foreign-key
664
+ 500.
665
+ - **Rate limited 10/minute per address** across both endpoints, on one counter — valid and invalid
666
+ tokens are not counted separately, which would be a way to tell them apart.
667
+ - **Expired and spent rows are swept** by `auth.revoke-all-token-purge` (daily 06:00), part of
668
+ `authJobRouter`: a week after expiry, a day after being spent or superseded.
669
+
670
+ **Settings.**
671
+
672
+ | Variable | Default | Meaning |
673
+ |----------|---------|---------|
674
+ | `SPFN_AUTH_REVOKE_ALL_LINK_TTL_MINUTES` | `30` | how long the link works |
675
+ | `SPFN_AUTH_REVOKE_ALL_CONFIRM_PATH` | `/account/revoke-all` | the page in your app the link opens |
676
+
677
+ The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the other
678
+ two links use. Only the SHA-256 of the token is stored, in `spfn_auth.key_revoke_all_tokens`.
679
+
680
+ Neither route is in the mobile contract: both are answered for a browser on a page in your app,
681
+ with no session and no client proof, and a generated mobile client has a session by definition.
682
+
683
+ ### Passkeys (WebAuthn)
684
+
685
+ A passkey is an **optional additional credential** on an account, alongside a password and a
686
+ linked social account rather than in place of either. Enroll one from a session that already
687
+ exists; sign in with it afterwards without typing an identifier at all.
688
+
689
+ ```
690
+ enroll → register/options (session) → the browser mints a credential → register/verify
691
+ sign in → login/options (public) → the browser picks a credential → login/verify
692
+ manage → list / rename / revoke
693
+ ```
694
+
695
+ **A passkey is not a device key.** The assertion proves *who* is asking; the device key the
696
+ Next.js proxy registers right after it is what every later request is signed with, exactly as
697
+ after a password login. Nothing in clientProofV1, in the JWT path, or in
698
+ [Registered devices](#registered-devices-key-management) changes because a session started
699
+ this way — a passkey sign-in produces the same `LoginResult` and the same key row as `login`.
700
+
701
+ #### Setup
702
+
703
+ ```bash
704
+ # .env.server — nothing is required; these are the overrides
705
+ SPFN_AUTH_PASSKEY_RP_ID=example.com
706
+ SPFN_AUTH_PASSKEY_ORIGINS=https://app.example.com,https://admin.example.com
707
+ ```
708
+
709
+ With neither set, the relying party is derived from `{NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}`:
710
+ its host becomes the rpId and its origin becomes the single allowed origin. That is the whole
711
+ configuration for a one-origin app.
712
+
713
+ | Var | File | Notes |
714
+ |-----|------|-------|
715
+ | `SPFN_AUTH_PASSKEY_RP_ID` | `.env.server` | domain credentials are bound to — no protocol, no port. Default: the app URL's host. **Changing it orphans every passkey already enrolled** |
716
+ | `SPFN_AUTH_PASSKEY_RP_NAME` | `.env.server` | name the authenticator's own prompt shows. Default: the rpId |
717
+ | `SPFN_AUTH_PASSKEY_ORIGINS` | `.env.server` | comma-separated full origins allowed to run a ceremony. Default: the app URL's origin |
718
+ | `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | `preferred` (default) or `required`. `discouraged` refuses boot |
719
+ | `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` | `.env.server` | default `300` — one ceremony at the authenticator, not an abandoned tab |
720
+ | `SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES` | `.env.server` | default `10` — see [the recent-authentication gate](#the-recent-authentication-gate) |
721
+
722
+ Two rules on those origins, **checked at boot** and refused with `PasskeyConfigError`:
723
+
724
+ - each origin must be `https`, and `localhost` is the one host a browser treats as a secure
725
+ context over plain `http` — so `http://localhost:3000` is legal and `http://app.example.com`
726
+ is not;
727
+ - each origin's host must be the rpId or a subdomain of it, because the browser will refuse
728
+ the ceremony otherwise.
729
+
730
+ The check runs at `initializeAuth`, deliberately: every one of these values makes *every*
731
+ passkey operation fail, the drift is between environments, and the deploy that introduces it
732
+ is where it has to surface — not the first sign-in after it.
733
+
734
+ **Boot is only refused for a configuration you wrote.** If no `SPFN_AUTH_PASSKEY_*` variable
735
+ is set, the derived relying party can still be unusable — `SPFN_APP_URL=http://192.168.1.5:3000`
736
+ so a phone on the same network can reach your laptop, say, which is neither https nor
737
+ localhost. Refusing to start over a feature nobody asked for would take that app down to fix
738
+ something it does not use, so it is logged once instead and only a ceremony fails. Set any
739
+ passkey variable and the same configuration refuses to start. This is the posture
740
+ [the OAuth callback origin check](#oauth-callback-origin-web-app-host--rewrite) already takes.
741
+
742
+ #### Enrolling, from a Next.js client component
743
+
744
+ ```tsx
745
+ 'use client';
746
+ import { authApi } from '@spfn/auth';
747
+ import { enrollPasskey, isPasskeySupported } from '@spfn/auth/client';
748
+
749
+ async function addPasskey()
750
+ {
751
+ const result = await enrollPasskey(authApi, { label: 'MacBook Touch ID' });
752
+
753
+ if (!result.ok)
754
+ {
755
+ // 'unsupported' | 'cancelled' | 'error' — 'cancelled' is not an error to show
756
+ return result.reason === 'cancelled' ? undefined : showError(result.reason);
757
+ }
758
+
759
+ showAdded(result.passkeyId, result.label);
760
+ }
761
+ ```
762
+
763
+ `isPasskeySupported()` is what decides whether to render the button at all.
764
+
765
+ #### Signing in, with conditional UI
766
+
767
+ The passkey appears in the browser's ordinary autofill dropdown. That needs an input whose
768
+ `autocomplete` ends in `webauthn`, and a `signInWithPasskey` call started **when the form
769
+ renders**, not on a click:
770
+
771
+ ```tsx
772
+ 'use client';
773
+ import { useEffect } from 'react';
774
+ import { authApi } from '@spfn/auth';
775
+ import { isConditionalMediationAvailable, signInWithPasskey } from '@spfn/auth/client';
776
+
777
+ export function SignInForm()
778
+ {
779
+ useEffect(() =>
780
+ {
781
+ void (async () =>
782
+ {
783
+ if (!await isConditionalMediationAvailable()) return;
784
+
785
+ const result = await signInWithPasskey(authApi, { conditional: true });
786
+ if (result.ok) router.replace('/');
787
+ })();
788
+ }, []);
789
+
790
+ return (
791
+ <form>
792
+ <input name="email" autoComplete="username webauthn" />
793
+ <input name="password" type="password" autoComplete="current-password" />
794
+ </form>
795
+ );
796
+ }
797
+ ```
798
+
799
+ Where conditional mediation is missing, render a visible "Sign in with a passkey" button that
800
+ calls `signInWithPasskey(authApi)` instead.
801
+
802
+ Both helpers answer with a discriminated union and **never throw a cancellation**: a person
803
+ who dismisses the system sheet raises `NotAllowedError`, and so does a person whose
804
+ authenticator had nothing to offer — neither is an application error, and code that has to
805
+ tell them apart by re-reading `error.name` gets it wrong once and shows a red banner to
806
+ someone who simply changed their mind.
807
+
808
+ | result | meaning |
809
+ |--------|---------|
810
+ | `{ ok: true, ... }` | signed in / enrolled; the rest of the object is the server's answer |
811
+ | `{ ok: false, reason: 'unsupported' }` | this browser has no WebAuthn; nothing was sent to the server |
812
+ | `{ ok: false, reason: 'cancelled' }` | the person dismissed the enrollment prompt |
813
+ | `{ ok: false, reason: 'no-credential' }` | sign-in: the authenticator offered nothing, or the person dismissed it |
814
+ | `{ ok: false, reason: 'error', error }` | anything else, with the original error attached |
815
+
816
+ #### The recent-authentication gate
817
+
818
+ Adding a credential is adding a way in, and removing one can lock an account. Both are refused
819
+ unless the caller has recently proved themselves, in one of two ways:
820
+
821
+ - **the device key this request is signed with was registered within
822
+ `SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES`** — that is when this device last presented a
823
+ credential, and it needs no new state; or
824
+ - **the body carries `currentPassword`** and it verifies.
825
+
826
+ Otherwise: **403 with `code: 'RECENT_AUTH_REQUIRED'`**. Branch on that code to prompt for the
827
+ password and retry — it is a stable field, not a message to match on.
828
+
829
+ An account with no password stored **cannot** satisfy the gate with a password, however
830
+ plausible the value; it has to sign in again. The comparison still runs, against a dummy hash,
831
+ so "no password on file" costs exactly what "wrong password" costs — otherwise response time
832
+ becomes an oracle for which accounts are OAuth-only.
833
+
834
+ #### Managing passkeys
835
+
836
+ ```typescript
837
+ const { passkeys } = await authApi.listPasskeys.call({ body: {} });
838
+ // → [{ passkeyId, label, deviceType, backedUp, transports, createdAt, lastUsedAt }]
839
+
840
+ await authApi.renamePasskey.call({ body: { passkeyId, label: 'Old iPhone' } });
841
+ await authApi.revokePasskey.call({ body: { passkeyId } });
842
+ ```
843
+
844
+ - **Neither `credentialId` nor the public key is ever returned.** They are what an
845
+ authenticator is addressed by; the list exists to let someone recognise a credential and
846
+ point at it, which the label, the device type and the last-used moment do.
847
+ - **`deviceType` is `singleDevice` or `multiDevice`**, and `backedUp` says whether a
848
+ multi-device credential actually has been. "This one only exists on that phone" is what the
849
+ owner needs before revoking the other entry.
850
+ - **Revocation is soft, and the credential id stays reserved for good.** A credential someone
851
+ cut off can never be enrolled again — not on another account, and not on the same one
852
+ (`PasskeyAlreadyRegisteredError`, 409). Re-enrolling means a fresh credential.
853
+ - **A passkey id you do not own answers 404.** Every lookup is owner-scoped, so the answer is
854
+ only ever "not yours".
855
+ - **Renaming has no recent-authentication gate**: a label is display only and nothing is
856
+ authorized by it.
857
+
858
+ #### Recovery — read this before shipping a passkey-only sign-up
859
+
860
+ The ways back into an account are: a live passkey, a password, a linked social account, or a
861
+ verified email address — the last one because [Password reset](#password-reset-verified-email)
862
+ can always give such an account a password back. Nothing else; support cannot restore an
863
+ account that has none of the four.
864
+
865
+ That is why **revoking the last live passkey is refused (409, `code:
866
+ 'LAST_RECOVERY_CREDENTIAL'`) when the account has no password, no linked social account and no
867
+ verified email.** A phone-only account is the case that reaches it. The refusal is not
868
+ paternalism; it is the absence of an undo. Branch on that code to offer "set a password
869
+ first", "link an account first", or "confirm your email address first".
870
+
871
+ The same fact should shape your sign-up: an account created without a password, without an
872
+ email and given one passkey has exactly one way in, and losing the device loses the account.
873
+ Ask for a password, an address, or a social link before, or shortly after, the passkey.
874
+
875
+ #### How the ceremonies are kept honest
876
+
877
+ - **Discoverable credentials only** (`residentKey: 'required'`). `login/options` takes an empty
878
+ body — `additionalProperties: false`, so an `email` field is a 400 rather than something
879
+ quietly ignored — and always answers with an empty `allowCredentials`. There is no input
880
+ that could make its answer differ by whether an account exists.
881
+ - **A revoked credential and one that was never here answer identically** on `login/verify`.
882
+ Anything else would say whether this account once had it.
883
+ - **Challenges are one-time database rows**, spent by a single conditional `UPDATE`. Two
884
+ verifies arriving with the same challenge produce one winner and one refusal, across
885
+ instances. A challenge is bound to its ceremony (`registration` / `authentication`) and, for
886
+ enrollment, to the account that minted it.
887
+ - **A refusal leaves the challenge live.** Spending happens inside the transaction that writes
888
+ what it authorizes, so a failure rolls it back and the ceremony is retryable; only a success
889
+ is unrepeatable.
890
+ - **A signature counter that goes backwards refuses the sign-in and leaves the row alone.** It
891
+ is the signal a cloned authenticator would produce — but a synced passkey reports 0 forever
892
+ and a restored device can hit it, so auto-revoking would lock people out on a false
893
+ positive. The refusal is logged at `warn` with the passkey id; a human decides what it meant.
894
+ - **Attestation is `none`.** Verifying an attestation statement would tell us which
895
+ authenticator model was used and nothing about who is holding it.
896
+
897
+ #### Errors
898
+
899
+ | error | status | `code` | when |
900
+ |-------|--------|--------|------|
901
+ | `PasskeyChallengeError` | 401 | — | the challenge is unknown, expired, already spent, of the other ceremony, or of another account |
902
+ | `PasskeyVerificationError` | 401 | — | origin, rpId, signature or counter — and, on sign-in, an unknown or revoked credential |
903
+ | `PasskeyNotFoundError` | 404 | — | a passkey the caller does not own, or one already revoked |
904
+ | `PasskeyAlreadyRegisteredError` | 409 | — | that credential is on file for some account, revoked ones included |
905
+ | `RecentAuthenticationRequiredError` | 403 | `RECENT_AUTH_REQUIRED` | the session proved itself too long ago and carried no password |
906
+ | `LastRecoveryCredentialError` | 409 | `LAST_RECOVERY_CREDENTIAL` | revoking it would leave no way back in |
907
+ | `PasskeyConfigError` | boot | — | an origin off the rpId or not https, or an unsupported user-verification value |
908
+
909
+ #### Events
910
+
911
+ `passkeyEnrolledEvent` (`auth.passkey.enrolled`: `userId`, `passkeyId`, `label?`) and
912
+ `passkeyRevokedEvent` (`auth.passkey.revoked`: `userId`, `passkeyId`, `reason`) fire after
913
+ commit. `authLoginEvent.provider` gains `'passkey'`. Subscribe to the first to tell the owner
914
+ a new way into their account appeared — which is what it is.
915
+
916
+ #### The case table
917
+
918
+ The behaviour above is asserted row by row in
919
+ `src/__tests__/integration/passkeys.test.ts`; each `it` is named for its row.
920
+
921
+ | row | situation | outcome |
922
+ |-----|-----------|---------|
923
+ | E1 | fresh session, no passkeys | 200, empty `excludeCredentials` |
924
+ | E2 | session key 11 min old, no password | 403 `RECENT_AUTH_REQUIRED` |
925
+ | E3 / E4 | 11 min old, correct / wrong password | 200 / 403 — byte-identical to E2 |
926
+ | E5 | no password on the account, 11 min old | 403; a password can never speak for it |
927
+ | E6 | valid attestation | 200; row written, challenge spent, event emitted |
928
+ | E7 / E8 | challenge replayed / expired | 401; one row, no row |
929
+ | E9 / E10 | another account's / the other ceremony's challenge | 401 |
930
+ | E11 | credential already on some account | 409 |
931
+ | E12 / E13 | wrong origin / wrong rpId | 401 |
932
+ | E14 | two live passkeys | both listed in `excludeCredentials` |
933
+ | E15 | label empty or over 64 chars | 400; challenge stays live |
934
+ | E16 | two concurrent verifies, one challenge | one 200, one 401, one row |
935
+ | L1 / L2 | empty options body / an `email` in it | 200 with empty `allowCredentials` / 400 |
936
+ | L3 | valid assertion | 200, same answer as `login`; counter and device key move |
937
+ | L4 / L5 | revoked / unknown credential | 401, byte-identical |
938
+ | L6 / L7 / L8 | challenge spent / expired / wrong kind | 401; no device key |
939
+ | L9 / L17 | bad signature / wrong origin | 401; counter unmoved |
940
+ | L10 | counter went backwards | 401; row untouched, warn logged, not revoked |
941
+ | L11 | synced passkey reporting 0 both times | 200 |
942
+ | L12 / L13 | disabled / pending deletion | 403, the same errors password login gives |
943
+ | L14 | device-key fields missing (proxy bypassed) | 400; challenge stays live |
944
+ | L15 | an old session key named in the body | it is revoked as the new one is registered |
945
+ | L16 | two concurrent verifies, one assertion | one 200, one 401, one device key |
946
+ | M1 | 2 live + 1 revoked | 2 entries, no credential id, no public key |
947
+ | M2 / M9 | someone else's / an already revoked passkey | 404 |
948
+ | M3 / M4 | rename / revoke on a recent session | 200; revoke emits its event |
949
+ | M5 | revoke on an 11-minute-old session | 403 `RECENT_AUTH_REQUIRED` |
950
+ | M6 / M7 / M8 | last passkey, no password: alone / with a social account / with a second passkey | 409 / 200 / 200 |
951
+ | M10 | re-enrolling a revoked credential | 409 |
952
+ | K1 / K4 | two concurrent revokes: 2 passkeys and nothing else / 1 passkey and a password | 200 + 409 / 200 + 404 |
953
+ | K2 / K3 | last passkey, no password: with a verified email / phone-only | 200 / 409 |
954
+
955
+ Configuration rows C1–C6 are in `src/__tests__/unit/passkey-config.test.ts`.
956
+
281
957
  ### Writing protected routes (route DSL)
282
958
 
283
959
  This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
@@ -360,6 +1036,16 @@ Both convenience URL APIs seal `metadata` into the encrypted OAuth state. On a n
360
1036
  signup, the callback passes it to `beforeRegister` and `authRegisterEvent`; existing-account
361
1037
  logins do not run the registration hook.
362
1038
 
1039
+ `returnUrl` must be a path inside your app — absolute URLs, `//host`, `..`, a backslash, and a
1040
+ tab/CR/LF (which a URL parser strips, turning `/<tab>/host` into `//host`) are refused, so a
1041
+ real login cannot become an open redirect. The start seams answer an unsafe value with a 400
1042
+ `ValidationError`; the seams that already hold a logged-in user replace the destination instead
1043
+ of failing the login — `OAuthCallback` navigates to `/` and `createOAuthCallbackHandler`
1044
+ redirects to its `defaultRedirectUrl` (`/` unless you pass one). The rule is exported as
1045
+ `isSafeReturnPath` from `@spfn/auth/server`, `@spfn/auth/nextjs/server`, and
1046
+ `@spfn/auth/nextjs/client` for apps that validate a destination before calling
1047
+ `getGoogleOAuthUrl`.
1048
+
363
1049
  Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
364
1050
  `GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
365
1051
  plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
@@ -446,9 +1132,25 @@ process), which differs from the API process in a split deployment — the callb
446
1132
  matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
447
1133
  coordination is needed.
448
1134
 
1135
+ An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` is checked when the server boots, because the
1136
+ value used to be read lazily on the first OAuth request and a wrong one surfaced much later as
1137
+ a CSRF refusal nobody traced back to it. A value that does not parse, or whose origin is not the
1138
+ web app origin, or whose path is not `/_auth/oauth/<provider>/callback`, refuses to start — one
1139
+ error naming every offending variable:
1140
+
1141
+ ```
1142
+ SPFN_AUTH_GOOGLE_REDIRECT_URI must be on the web app origin (http://localhost:3790) at
1143
+ /_auth/oauth/google/callback: the callback's CSRF cookie is host-only and /_auth/* is forwarded
1144
+ to the API by the app's rewrite. Unset it to use the default, fix the origin, or set
1145
+ SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off for a deployment that deliberately terminates the
1146
+ callback elsewhere.
1147
+ ```
1148
+
449
1149
  One caveat: the direct `POST /_auth/oauth/start` flow (no Next.js interceptor) sets its CSRF
450
- cookie on the **API host**. If you use that flow in a split deployment, set
451
- the corresponding provider redirect URI explicitly to the API host callback instead.
1150
+ cookie on the **API host**. If you use that flow in a split deployment, set the corresponding
1151
+ provider redirect URI explicitly to the API host callback **and**
1152
+ `SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off` — that is the one deployment the check is wrong
1153
+ about, and `off` is the only value that disables it.
452
1154
 
453
1155
  ### Native social sign-in (mobile / web id_token)
454
1156
 
@@ -696,6 +1398,155 @@ export default async function AdminPage()
696
1398
  Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
697
1399
  `hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
698
1400
 
1401
+ ### Emptying the cookie jar from a route handler or middleware
1402
+
1403
+ `clearSession()` works where `next/headers` is writable. The page that answers *the API
1404
+ refused your session* is usually a route handler or middleware holding a `NextResponse`
1405
+ instead — `clearSessionCookies(response)` expires the session, key-id, OAuth-pending and
1406
+ CSRF cookies on it and returns the same response, so the call chains:
1407
+
1408
+ ```typescript
1409
+ import { clearSessionCookies } from '@spfn/auth/nextjs/server';
1410
+
1411
+ export function GET(request: NextRequest)
1412
+ {
1413
+ return clearSessionCookies(NextResponse.redirect(new URL('/login', request.url)));
1414
+ }
1415
+ ```
1416
+
1417
+ Never spell the names in your app. They carry an `SPFN_PORT` suffix (`spfn_session_4001`),
1418
+ so two dev instances do not overwrite each other's cookies, and a hand-written copy of that
1419
+ rule clears the wrong cookie without failing. Read them from `sessionCookieNames()`, which
1420
+ returns `{ session, keyId, oauthPending, csrf }` at call time.
1421
+
1422
+ ## CSRF protection
1423
+
1424
+ Cookie-authenticated mutations carry a CSRF token by default. Nothing to write: the
1425
+ Next.js proxy issues the token with the session and the api client sends it back.
1426
+
1427
+ **What it protects.** The session cookie is `SameSite=Lax`, which already blocks the
1428
+ classic cross-site form POST. What remains is what Lax does not cover: a sibling
1429
+ subdomain that can write cookies on your parent domain (an XSS on `blog.example.com`
1430
+ against `app.example.com`), browsers that predate or mis-implement Lax, and a domain
1431
+ layout that drifts into `SameSite=None` later. This closes those.
1432
+
1433
+ **What it does not protect.** Nothing here helps against XSS on your own origin.
1434
+ Script running on your origin can read the token cookie and call your API as the
1435
+ user — that is true of every CSRF scheme, and no token design changes it. Same-origin
1436
+ XSS is out of scope; Content-Security-Policy and output escaping are the answer to it.
1437
+
1438
+ ### How it works
1439
+
1440
+ - On login, OAuth finalize, key rotation and every session renewal, the proxy sets
1441
+ `spfn_csrf` — a readable (non-HttpOnly) cookie holding only an HMAC of the session's
1442
+ key id, keyed by a subkey derived from `SPFN_AUTH_SESSION_SECRET`. No new variable,
1443
+ and the raw session secret is never used as the token key. Sessions that predate
1444
+ the feature get one on their first authenticated response, so upgrading does not
1445
+ require anyone to sign in again.
1446
+ - The api client mirrors the cookie into the `x-spfn-csrf` header on **every** RPC call,
1447
+ GET-shaped ones included — see "Which requests are checked" for why it cannot narrow
1448
+ that itself. Where the header is *checked* is the proxy's decision, not the client's.
1449
+ - The proxy **recomputes** the expected value from the session it just unsealed and
1450
+ compares it to the header, in constant time. It never compares the cookie to the
1451
+ header — that is the classic double-submit weakness, and it is exactly what a
1452
+ sibling subdomain defeats by tossing a cookie it chose. A tossed cookie fails here.
1453
+ - The token derives from the session key id, so rotating the key invalidates it. The
1454
+ proxy reissues the cookie in the same response that rotates or renews the session.
1455
+
1456
+ The check runs in the proxy, not the backend, because only the proxy knows the
1457
+ request's credential was ambient: it turns the session cookie into a short-lived
1458
+ bearer JWT, so the backend sees `scheme:'bearer'` for cookie callers and for genuine
1459
+ bearer clients alike.
1460
+
1461
+ ### Which requests are checked
1462
+
1463
+ Only requests the proxy authenticates from the session cookie, and only when the
1464
+ resolved **route** method is not GET/HEAD/OPTIONS.
1465
+
1466
+ Route method, not the method the browser used to reach the proxy. The api client picks
1467
+ its wire method from whether the input has a body, and holds no route map — that is the
1468
+ point of "no metadata codegen required" — so a mutation with nothing to send travels as
1469
+ GET. `logout` is `POST /_auth/logout`; `revokeOpsToken` is
1470
+ `DELETE /_auth/ops-tokens/:id`, called with only a path param. Both are `GET` on the
1471
+ wire and both are forwarded as the route's real method. A client that withheld the
1472
+ header on GET-shaped calls would therefore 403 them under `enforce`, which is why the
1473
+ contract is "every call carries it" and the proxy alone decides where it is checked.
1474
+ Gating in the proxy on the wire method would be worse still: a cross-site top-level GET
1475
+ navigation *does* carry a `SameSite=Lax` cookie, so every mutation would stay reachable
1476
+ that way.
1477
+
1478
+ Untouched, by construction: requests with no session, direct-to-backend bearer
1479
+ clients, `clientProofV1` mobile callers, machine and ops tokens. None of them pass
1480
+ through this code. A request without a session is answered exactly as before (the
1481
+ backend returns 401) — a CSRF refusal only ever answers an authenticated request, so
1482
+ the refusal itself cannot tell an anonymous caller whether anyone is signed in.
1483
+
1484
+ ### Modes
1485
+
1486
+ | Mode | Behaviour |
1487
+ |---|---|
1488
+ | `off` | No check. |
1489
+ | `warn` | **Default.** Allows the request, logs one line per request that would be refused. |
1490
+ | `enforce` | Refuses with `403 {"error":"Forbidden","message":"CSRF token missing or invalid"}`. |
1491
+
1492
+ Existing apps get signal before breakage: unset means `warn`. Watch for
1493
+ `@spfn/auth:interceptor:csrf` lines, then switch on. Apps scaffolded by `spfn init`
1494
+ start at `enforce`.
1495
+
1496
+ ```bash
1497
+ # .env.local — read by the Next.js process, where the proxy runs
1498
+ SPFN_AUTH_CSRF=enforce
1499
+ ```
1500
+
1501
+ ```typescript
1502
+ import { configureAuth } from '@spfn/auth/server';
1503
+
1504
+ configureAuth({
1505
+ csrf: {
1506
+ mode: 'enforce',
1507
+ // Exact backend route paths, params already substituted — not /api/rpc/… URLs.
1508
+ // For endpoints a browser session never calls, e.g. webhook receivers that
1509
+ // authenticate themselves by signature. An exempt path is unprotected for
1510
+ // cookie callers too, so list only endpoints that carry their own auth.
1511
+ exemptPaths: ['/webhooks/stripe'],
1512
+ },
1513
+ });
1514
+ ```
1515
+
1516
+ `configureAuth` wins over the environment variable. `enforce` and `warn` both need
1517
+ `SPFN_AUTH_SESSION_SECRET` — sessions need it anyway — and refuse rather than quietly
1518
+ passing everything if it is missing.
1519
+
1520
+ ### If a request is refused
1521
+
1522
+ A refusal in a running app almost always means the token cookie is gone or stale while
1523
+ the session is not — cleared by hand or by an extension, or a session that predates this
1524
+ feature. Rotation is not a cause: the response that rotates the key reissues the cookie
1525
+ in the same breath, and one browser has one jar, so other tabs pick the new value up
1526
+ with it.
1527
+
1528
+ Two things repair it, and both are mechanical:
1529
+
1530
+ - **The 403 carries the fix.** The proxy is the one emitting the refusal, so it sets a
1531
+ fresh `spfn_csrf` on that very response. A browser that repeats the mutation succeeds.
1532
+ The refusal is otherwise unchanged — same status, same body.
1533
+ - **Any authenticated response reissues a wrong one.** A response whose request arrived
1534
+ with no CSRF cookie, or with one that no longer matches the session, queues the
1535
+ correct value. A cookie that is merely *present* is not taken as proof it is right.
1536
+
1537
+ **The client does not retry a refused call**, so a user sees one failure before the
1538
+ repaired state takes effect — the framework fixes the browser, not the click.
1539
+
1540
+ **Limitation — calls made from the server.** A Server Component cannot set cookies at
1541
+ all, and Next.js does not forward `Set-Cookie` from a fetch the api client made on the
1542
+ server to the browser. So neither repair reaches the jar when the refused call came from
1543
+ a Server Component, a Server Action or a Route Handler; the next browser-originated
1544
+ request through the proxy is what heals it. Server-side callers otherwise need no
1545
+ change: the api client reads the whole jar through `next/headers`, and an explicit
1546
+ `cookies` option merges over that rather than replacing it. Only a caller that
1547
+ hand-builds a jar somewhere `cookies()` cannot be reached — build time, static
1548
+ generation — has to include the CSRF cookie itself.
1549
+
699
1550
  ## How do I define roles and permissions?
700
1551
 
701
1552
  Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
@@ -780,7 +1631,7 @@ control, not authorization.
780
1631
  analytics, onboarding, etc. Client-supplied `metadata` on register/OAuth flows is forwarded verbatim.
781
1632
 
782
1633
  ```typescript
783
- import { authLoginEvent, authRegisterEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
1634
+ import { authLoginEvent, authRegisterEvent, authDeviceRegisteredEvent, invitationCreatedEvent, invitationAcceptedEvent } from '@spfn/auth/server';
784
1635
 
785
1636
  authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
786
1637
  {
@@ -788,10 +1639,44 @@ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
788
1639
  });
789
1640
  ```
790
1641
 
791
- Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
1642
+ `authLoginEvent`'s `provider` is `'email'`, `'phone'`, a social provider, `'device'` or
1643
+ `'passkey'`. `'device'` is a [device-code login](#device-code-login), where the account was
1644
+ proven on another device that was already signed in and no credential was presented here;
1645
+ `'passkey'` is a [WebAuthn assertion](#passkeys-webauthn). `authRegisterEvent` accepts
1646
+ neither: a device-code request can only ever be approved by an account that already exists,
1647
+ and a passkey has to be enrolled from a session that already exists, so neither is a signup.
1648
+
1649
+ `passkeyEnrolledEvent` (`auth.passkey.enrolled`) and `passkeyRevokedEvent`
1650
+ (`auth.passkey.revoked`) fire after commit when a passkey is added or retired.
1651
+
1652
+ `authPasswordResetEvent` (`auth.password.reset`: `userId`, `email`) fires after commit when a
1653
+ [password reset](#password-reset-verified-email) completes. Distinct from a password
1654
+ *change*, which is made from a session that already proved itself: this one is made by
1655
+ whoever opened a link in a mailbox, so it is the notice to send the owner.
1656
+
1657
+ `authDeviceRegisteredEvent` (`auth.device.registered`) fires after commit whenever a device key is
1658
+ registered on an account, on every channel that registers one — `channel` says which: `register`,
1659
+ `signup-link`, `invitation`, `password`, `oauth`, `oauth-native`, `device-code`, `password-reset`
1660
+ or `passkey`. It carries `userId`, `keyId`, `algorithm`, a 12-character `fingerprintPrefix`,
1661
+ `createdAtMillis`, and whatever the registration knew about the device: `deviceName?`, `platform?`,
1662
+ `ip?` and `userAgent?` — the web OAuth callback has neither label, because the sealed state does
1663
+ not carry them. Subscribe to tell the owner a device was added: a login event says a session began
1664
+ and not what it began on, so a stolen password used on a new machine was silent until this event.
1665
+ Send it with a [sign-out-everywhere link](#the-sign-out-everywhere-link), which is the action the
1666
+ notice should offer.
1667
+
1668
+ Key **rotation** is deliberately not announced — replacing the key of a device that is already
1669
+ signed in is not a new device, and a notice for it would teach the owner to ignore the ones that
1670
+ matter. A login that names an `oldKeyId` is only a rotation when that key was actually revoked: an
1671
+ `oldKeyId` naming somebody else's key, an already-revoked one or nothing at all registers a new
1672
+ device and fires the event. `ip` and `userAgent` are unauthenticated and display-only.
1673
+
1674
+ Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayload`,
1675
+ `AuthDeviceRegisteredPayload`, `InvitationCreatedPayload`,
792
1676
  `InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
793
1677
  `AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
794
- disconnect, see the OAuth unlink-notify section). These events also bind to `@spfn/core/job`
1678
+ disconnect, see the OAuth unlink-notify section), `PasskeyEnrolledPayload`,
1679
+ `PasskeyRevokedPayload`. These events also bind to `@spfn/core/job`
795
1680
  jobs via `.on(event)`.
796
1681
 
797
1682
  ## Registration gate (`beforeRegister`)
@@ -826,6 +1711,8 @@ Notes:
826
1711
  seeding in `initializeAuth()`.
827
1712
  - OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
828
1713
  per channel (reject, or allow and collect during onboarding).
1714
+ - `email` arrives trimmed and lower-cased, the same form the account is stored under, so a
1715
+ denylist or domain allowlist keyed on the address is not walked past by capitalizing it.
829
1716
  - On the `oauth` channel `email` is the provider-reported address and may be **unverified**
830
1717
  (the created account then stores `email` as `null`). The context carries
831
1718
  `emailVerified` — an email-based allow/block policy must check it before trusting `email`.
@@ -860,6 +1747,13 @@ export const opsRouter = createOpsRouter({
860
1747
  }, { auth: opsTokenAuth });
861
1748
  ```
862
1749
 
1750
+ An application that admits both credentials on one route has to tell an ops token from a
1751
+ session JWT *before* either is verified. Do not re-type the literal: `isOpsToken(bearer)`
1752
+ and the `OPS_TOKEN_PREFIX` it tests against are both exported from `@spfn/auth/server`, so
1753
+ the shape has one definition and a copy in application code cannot drift from it.
1754
+ `isOpsToken` answers shape only — it takes a raw header value, returns `false` for a
1755
+ missing or non-string one, and leaves unknown/revoked/expired to verification.
1756
+
863
1757
  `opsRoute` comes from `@spfn/core` **0.3.0-beta.2** onwards; before that release an ops
864
1758
  route spelled its own `/_ops/` prefix with `route`.
865
1759
 
@@ -903,6 +1797,81 @@ same 401, so whether a presented secret ever existed is not inferable. A valid t
903
1797
  missing a route's scope answers 403 naming only the missing scope. `'*'` grants every
904
1798
  scope.
905
1799
 
1800
+ ### One route, two credentials (`opsOrUser`)
1801
+
1802
+ An operator action is scripted today — the CLI, holding an ops token — and driven from an
1803
+ admin console tomorrow, a browser holding a user session. That is one route with two
1804
+ admissible credentials, and neither middleware admits both: `authenticate` refuses an
1805
+ `spfn_ops_` bearer before any scope guard runs (see [Machine principals](#machine-principals-registermachineverifier)),
1806
+ and `opsTokenAuth` admits nothing else.
1807
+
1808
+ ```typescript
1809
+ import { opsOrUser, getAuth, getOpsToken } from '@spfn/auth/server';
1810
+
1811
+ export const exportSignups = route.get('/admin/signups/export')
1812
+ .use([opsOrUser({ opsScopes: ['waitlist:read'], permissions: ['admin.waitlist'] })])
1813
+ // or by role: opsOrUser({ opsScopes: ['waitlist:read'], roles: ['admin'] })
1814
+ // or both (AND): opsOrUser({ opsScopes: ['waitlist:read'], roles: ['admin'], permissions: ['admin.waitlist'] })
1815
+ .handler(async (c) =>
1816
+ {
1817
+ // exactly one of these is set
1818
+ const ops = getOpsToken(c.raw); // the ops branch
1819
+ const user = getAuth(c.raw); // the session branch
1820
+ });
1821
+ ```
1822
+
1823
+ **The branch is chosen by credential shape, never by caller choice.** The raw
1824
+ `Authorization` bearer is tested with [`isOpsToken`](#ops-tokens-spfn-ops); a match runs
1825
+ `opsTokenAuth` then `requireOpsScope(...opsScopes)`, and everything else — a user JWT,
1826
+ another machine namespace, a malformed header, no header — runs `authenticate` then the
1827
+ session guards. Nothing in the request selects a branch except the credential it presents,
1828
+ so a caller cannot ask for the weaker check.
1829
+
1830
+ **`roles` and `permissions` are AND, roles first.** Two lists only ever narrow. An OR would
1831
+ mean that adding one role voids the whole permission list, which is the opposite of what a
1832
+ reader of the two lists expects. Roles run first because the role is already on the auth
1833
+ context while permissions cost a lookup — so a caller with the wrong role is refused for the
1834
+ wrong role. Giving neither list is a definition-time error, as is an empty `opsScopes`: a
1835
+ configuration that would admit a credential unchecked fails at boot, not on a request.
1836
+
1837
+ **No implicit admin bypass.** Permissions match by name only, and the ops branch has no role
1838
+ concept, so neither branch has a principal that passes by virtue of being an administrator.
1839
+ A refusal is the selected branch's own refusal, with that branch's existing status and
1840
+ message — no error class and no wire message is introduced here.
1841
+
1842
+ `opsOrUser` carries `skips: ['auth']`, so a route using it auto-skips the server-level
1843
+ `auth` middleware exactly as `optionalAuth` and `opsTokenAuth` do. No `.skip(['auth'])` by
1844
+ hand.
1845
+
1846
+ **Cookies.** The backend never reads them. A browser session reaches a route as a Bearer
1847
+ token because `@spfn/auth/nextjs/api` forwards it as one, so through the app a console
1848
+ request is the session rows below; a request carrying only a `Cookie` header is an
1849
+ unauthenticated request here.
1850
+
1851
+ | bearer | branch | answer |
1852
+ |---|---|---|
1853
+ | `spfn_ops_…` valid, scope present (or `*`) | ops | 200; `getOpsToken` set, `getAuth` null |
1854
+ | `spfn_ops_…` valid, scope missing | ops | 403 `Ops token lacks scope` |
1855
+ | `spfn_ops_…` unknown / revoked / expired | ops | 401 `Invalid ops token` (one message for all three) |
1856
+ | `spfn_ops_` prefix alone | ops | 401 `Invalid ops token` |
1857
+ | user JWT valid, permission held | user | 200; `getAuth` set, `getOpsToken` null |
1858
+ | user JWT valid, permission missing | user | 403 `InsufficientPermissionsError` |
1859
+ | user JWT expired / bad signature | user | 401 (the existing `authenticate` message) |
1860
+ | token in a *registered* machine namespace, not ops | user | 401 — the user path admits no machine credential |
1861
+ | malformed bearer / no `Authorization` | user | 401 |
1862
+ | session cookie only, no bearer | user | 401 — see Cookies above |
1863
+ | `x-spfn-auth-profile` + user JWT | user | `PROFILE_REJECTED` (existing `authenticate` behaviour) |
1864
+ | `x-spfn-auth-profile` + ops token | ops | header ignored; `opsTokenAuth` reads `Authorization` only |
1865
+ | ops token on a plain `authenticate` route | — | 401, unchanged |
1866
+ | `opsScopes: []`, or neither `roles` nor `permissions` | — | throws at definition |
1867
+ | server-level `auth` registered | — | auto-skipped on this route |
1868
+ | `roles: ['admin']` only; role admin | user | 200 |
1869
+ | `roles: ['admin']` only; role user | user | 403 `InsufficientRoleError` |
1870
+ | `roles` + `permissions`; role matches, permission missing | user | 403 `InsufficientPermissionsError` |
1871
+ | `roles` + `permissions`; permission held, role wrong | user | 403 `InsufficientRoleError` (role is checked first) |
1872
+
1873
+ `opsOrUser` is available from **0.3.0-beta.11**.
1874
+
906
1875
  ## Mobile clientProofV1 (`@spfn/auth/client-proof`)
907
1876
 
908
1877
  Server side of the spfn-mobile native SDK auth profile (issue #46; asymmetric revision in
@@ -917,6 +1886,12 @@ the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` ·
917
1886
  `SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
918
1887
  HTTP status).
919
1888
 
1889
+ Before minting the first proof in each client process, the client calls the built-in
1890
+ `GET /_core/time` operation (`core.time`) and establishes its proof epoch from
1891
+ `serverTimeMillis`. This prerequisite is unproven and session-free. If the operation is
1892
+ unavailable or its response cannot be decoded, proof minting fails closed — there is no silent
1893
+ fallback to the device's unsynchronized wall clock.
1894
+
920
1895
  - Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
921
1896
  `x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
922
1897
  - A request body must be **byte-canonical** — a body that parses but re-encodes differently is
@@ -944,6 +1919,29 @@ HTTP status).
944
1919
  construction or through the `/control/register-key` hook; the private half never reaches
945
1920
  the server. No persistence — a production enrollment/rotation story is phase 2.
946
1921
 
1922
+ ### Clock synchronization and proof-time boundaries (contract 0.9.0)
1923
+
1924
+ `core.time` is imported from `@spfn/core` rather than restated by auth: operation ID, method,
1925
+ path, auth class, session requirement, and the closed `ServerTimeResponse` schema all come from
1926
+ the core route contract. The mobile contract records it as a bodyless GET prerequisite and
1927
+ requires one synchronization before the first proof minted in each process. It does not prescribe
1928
+ persistent offset storage, retry sleeps, or device-specific margins.
1929
+
1930
+ The server admission rule remains strict: `age = serverNow - issuedAtMillis` must satisfy
1931
+ `0 <= age <= 300000`. Synchronization does not widen the replay window or change nonce retention.
1932
+ A refused request still leaves its nonce unused; only admission spends it.
1933
+
1934
+ | `serverNow - issuedAtMillis` | Result |
1935
+ |---:|---|
1936
+ | `0` | accept |
1937
+ | `-1` (proof is 1 ms in the future) | `PROOF_EXPIRED` |
1938
+ | `300000` | accept |
1939
+ | `300001` | `PROOF_EXPIRED` |
1940
+
1941
+ When `core.time` cannot be read, the client must surface that synchronization failure and stop
1942
+ before sending a proof. Using `Date.now()` or a platform wall clock as an implicit fallback would
1943
+ reintroduce the skew failure this prerequisite closes.
1944
+
947
1945
  ### The contract version on the wire (contract 0.6.0)
948
1946
 
949
1947
  A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
@@ -992,6 +1990,8 @@ Every operation in the exported bundle carries `since` — the contract version
992
1990
  | `auth.clientProof.handshake`, `echo.send`, `items.list` | 0.1.0 |
993
1991
  | `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
994
1992
  | `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
1993
+ | `core.time` | 0.9.0 |
1994
+ | `auth.device.start`, `auth.device.poll`, `auth.device.info`, `auth.device.approve`, `auth.device.deny` | 0.10.0 |
995
1995
 
996
1996
  - **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
997
1997
  contract version passes or refuses the whole surface, so these three fields change no verdict here.
@@ -1063,6 +2063,386 @@ app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
1063
2063
  Responses and errors MUST be canonical bytes with the contract envelope — build them with
1064
2064
  `encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
1065
2065
 
2066
+ ## Custom auth profiles (`registerAuthProfile`)
2067
+
2068
+ `clientProofV1` is not a special case in the middleware — it is one entry in a registry
2069
+ `authenticate` and `optionalAuth` dispatch on. An app registers its own scheme the same way,
2070
+ without forking the middleware or wrapping it:
2071
+
2072
+ ```typescript
2073
+ import { registerAuthProfile, type AuthContext } from '@spfn/auth/server';
2074
+ import { UnauthorizedError } from '@spfn/core/errors';
2075
+
2076
+ // At boot — server.config.ts, before the server starts taking requests.
2077
+ registerAuthProfile('serviceTokenV1', {
2078
+ verify: async (c): Promise<AuthContext> =>
2079
+ {
2080
+ const user = await findServiceAccount(c.req.header('x-acme-service-token'));
2081
+ if (user === null)
2082
+ {
2083
+ // A refusal leaves the verifier as a throw. It reaches the app's
2084
+ // error handler exactly as the Bearer path's does.
2085
+ throw new UnauthorizedError({ message: 'Invalid service token' });
2086
+ }
2087
+
2088
+ return {
2089
+ user,
2090
+ userId: String(user.id),
2091
+ keyId: 'service-token',
2092
+ role: null,
2093
+ locale: 'en',
2094
+ scheme: 'serviceTokenV1',
2095
+ };
2096
+ },
2097
+ });
2098
+ ```
2099
+
2100
+ A request naming the profile is then answered by that verifier:
2101
+
2102
+ ```http
2103
+ POST /v1/reports
2104
+ x-spfn-auth-profile: serviceTokenV1
2105
+ x-acme-service-token: <the app's own credential>
2106
+ ```
2107
+
2108
+ - **Register at boot, before the first request.** The registry is read on every dispatch, so a
2109
+ profile registered later is simply a profile the requests before it did not have. Registration
2110
+ is not frozen after startup — it is a contract, not a runtime check.
2111
+ - **A duplicate name throws**, `clientProofV1` included. Replacing a registered verifier silently
2112
+ is how an import order or a copied profile name swaps the code that decides who is admitted, so
2113
+ there is no override — and no unregistration API for the same reason.
2114
+ - **The verifier must expose a callable `verify`**, and what it resolves must carry a `userId` — a
2115
+ verifier that cannot admit anyone is refused at boot, and a resolve without a principal (`null`,
2116
+ the JS idiom for "no user") is refused as a throw rather than routed as authenticated.
2117
+ - **An unknown profile is still refused** (`PROFILE_REJECTED`, 400): registering one name does not
2118
+ open the header to others.
2119
+ - **Mixing is still refused.** A request carrying both `x-spfn-auth-profile` and `Authorization` is
2120
+ rejected before either path runs; a custom verifier never sees it.
2121
+ - **A verifier's throw propagates**, and only the internal clientProofV1 contract refusal is
2122
+ answered with the canonical envelope. Under `optionalAuth` too: credentials that were presented
2123
+ and refused are never downgraded to anonymous passage — only "presented nothing" continues
2124
+ without an auth context.
2125
+ - **`AuthContext.scheme` is an open union** — `'bearer' | 'clientProofV1' | 'oneTimeToken' | (string
2126
+ & {})`. The built-in names keep their autocomplete and a registered profile names its own scheme.
2127
+ The field stays informational: downstream permission and tenant code takes one principal shape and
2128
+ never branches on how it was produced.
2129
+
2130
+ ## Authorization server for MCP clients
2131
+
2132
+ Let Claude Code and Codex connect to your app's `/mcp` endpoint as the user, over the flow
2133
+ they already speak: OAuth 2.1 with dynamic client registration and PKCE.
2134
+
2135
+ ```console
2136
+ $ claude mcp add --transport http acme https://api.acme.com/mcp
2137
+ $ claude
2138
+ > /mcp
2139
+ ```
2140
+
2141
+ Between those two lines the CLI discovers `/.well-known/oauth-authorization-server`, registers
2142
+ itself, opens a browser at your consent screen, catches the redirect on a loopback port, and
2143
+ exchanges the code for a token. Nobody pastes anything.
2144
+
2145
+ The feature is opt-in and the opt-in is one block:
2146
+
2147
+ ```typescript
2148
+ createAuthLifecycle({
2149
+ authorizationServer: {
2150
+ scopes: {
2151
+ 'mcp:read': 'Read your projects and tasks',
2152
+ 'mcp:write': 'Create and edit your tasks',
2153
+ },
2154
+ defaultScopes: ['mcp:read'], // what a request with no `scope` asks for. default: all of them
2155
+ // issuer: 'https://api.acme.com', // default: SPFN_API_URL
2156
+ // authorizeUrl: 'https://acme.com/oauth/authorize', // default: {app url}/oauth/authorize
2157
+ // allowedRedirectOrigins: [], // https origins a client may register. loopback needs no entry
2158
+ // accessTokenTtlMs: 8 * 60 * 60 * 1000, // default 8 hours
2159
+ // refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, // default 30 days
2160
+ // codeTtlMs: 60 * 1000, // default 60 seconds
2161
+ },
2162
+ })
2163
+ ```
2164
+
2165
+ Without that block every endpoint below answers 404 and nothing else changes — including the
2166
+ boot check, which does not run. `scopes` is the one setting with no default: the names are
2167
+ your application's vocabulary, they are published in the metadata document and read aloud on
2168
+ the consent screen, and there is nothing to derive them from.
2169
+
2170
+ | Endpoint | Host | Auth | What it is |
2171
+ | --- | --- | --- | --- |
2172
+ | `GET /.well-known/oauth-authorization-server` | API | public | RFC 8414 discovery — the first request any client makes |
2173
+ | `POST /_auth/oauth2/register` | API | public, IP rate limited | RFC 7591 dynamic registration. Public clients only |
2174
+ | `GET /_auth/oauth2/authorize` | API | `authenticate` | What the consent screen should say. Records nothing |
2175
+ | `POST /_auth/oauth2/authorize` | API | `authenticate` | The decision. Mints the code |
2176
+ | `POST /_auth/oauth2/token` | API | public, IP rate limited | `authorization_code` and `refresh_token` |
2177
+ | `POST /_auth/oauth2/revoke` | API | public (RFC 7009) | `client_id` required; 200 for an unknown token as surely as for a real one |
2178
+ | `GET /_auth/oauth2/grants` · `DELETE /_auth/oauth2/grants/:id` | API | `authenticate` | What the user has connected, and the button that disconnects it |
2179
+ | `GET /oauth/authorize` · `POST /oauth/authorize` | web | session | The consent screen itself — see the note at the end |
2180
+
2181
+ Two lines wire it to `@spfn/mcp`:
2182
+
2183
+ ```typescript
2184
+ import { verifyAccessToken } from '@spfn/auth/server';
2185
+
2186
+ export const mcp = createMcpRoute({ validateToken: verifyAccessToken, tools: [...] });
2187
+ ```
2188
+
2189
+ `verifyAccessToken(token, resource)` answers `{ clientId, scopes, expiresAt, userId }` or
2190
+ `null`, and `null` is a refusal — `@spfn/mcp` ≥ 0.3.0-beta.3 accepts it as one rather than
2191
+ requiring a throw. `expiresAt` is seconds since the epoch, like every other OAuth field here.
2192
+
2193
+ - **Only loopback and origins you allowed.** A client may register `http://localhost:*`,
2194
+ `http://127.0.0.1:*` or `http://[::1]:*` — a CLI cannot know which port the OS will hand it,
2195
+ so the **port** is the one thing allowed to vary. Nothing else does: host, path and query
2196
+ must match the registration exactly, a fragment is refused at registration and at request,
2197
+ and plain `http` anywhere else is refused outright. An `https` redirect URI has to be on an
2198
+ origin listed in `allowedRedirectOrigins`.
2199
+ - **Those three spellings are three registrations.** `localhost`, `127.0.0.1` and `[::1]` do
2200
+ not stand in for one another — they resolve differently on a machine with a split-horizon
2201
+ resolver, and a client answered on a host it did not register is a client something
2202
+ redirected. IPv6 is the one place spelling is folded: `http://[0:0:0:0:0:0:0:1]:5/cb` and
2203
+ `http://[::1]:5/cb` are the same registration, because both sides are read through
2204
+ `new URL(...).hostname`.
2205
+ - **An unknown client or a mismatched redirect URI is shown, never redirected.** There is no
2206
+ vetted URI to send that error to, and sending it to the one the request supplied is the open
2207
+ redirect the whole rule exists to close. Every other authorize-time error —
2208
+ `invalid_request`, `invalid_scope`, `invalid_target`, `access_denied` — goes back to the
2209
+ client on its registered URI, which is the only form the waiting CLI can read.
2210
+ - **PKCE S256, and nothing else.** No `plain`, and no request without a challenge. The code
2211
+ arrives on a loopback port that any process on the machine could have been listening on.
2212
+ - **`resource` is required** (RFC 8707) and the token is only good against it. A token your
2213
+ user approved for your MCP server cannot be replayed against a neighbouring deployment that
2214
+ shares this authorization server.
2215
+ - **A code is spent by the statement that reads it**, so of two exchanges arriving together
2216
+ exactly one gets tokens — and **presenting a code twice revokes the grant**, because by then
2217
+ somebody else may hold what the first exchange produced.
2218
+ - **Refresh tokens rotate, and a rotated one is marked rather than deleted.** Presenting it
2219
+ again revokes the grant, which kills the replacement as well as the replayed token: both
2220
+ hang off the grant and there is no telling which holder is the thief. A refresh may ask for
2221
+ a **subset** of the granted scopes and never for more; narrowing applies to that request and
2222
+ leaves the user's consent record as they gave it.
2223
+ - **Every code and refresh failure is one `invalid_grant`, word for word.** Unknown, expired,
2224
+ spent, wrong verifier, another client's. The endpoint is public, and an error that told
2225
+ those apart would answer the question somebody holding a stolen value is asking.
2226
+ - **Token endpoint errors are RFC 6749 §5.2, not the SPFN envelope** —
2227
+ `{ "error": "invalid_grant", "error_description": "..." }`, status 400,
2228
+ `Cache-Control: no-store`. The client reading it is an OAuth library that knows those two
2229
+ field names and nothing about this framework. Registration refusals are RFC 7591 §3.2.2 the
2230
+ same way (`invalid_redirect_uri`, `invalid_client_metadata`).
2231
+ - **Nothing but a hash is stored.** Codes and tokens are `spfn_at_<64 hex>` /
2232
+ `spfn_rt_<64 hex>` / 43 url-safe characters, and the value exists in the clear exactly once,
2233
+ in the response that issues it. It is never logged and never put in an event.
2234
+ - **A global revocation reaches the grants.** `revoke-all`, a password change, a completed
2235
+ password reset and a deletion request each revoke every grant the account has — so a CLI
2236
+ holding a refresh token through "sign me out everywhere" cannot be back within the hour,
2237
+ which is exactly the client that call was aimed at. The user's own
2238
+ `DELETE /_auth/oauth2/grants/:id` does the same for one client, immediately.
2239
+ - **The issuer is checked at boot.** It must be an absolute URL with no path — the metadata
2240
+ document is served at an origin's root and nowhere else — and it must be `https`, or `http`
2241
+ on `localhost` / `127.0.0.1` / `[::1]` for development. Anything else refuses to start with
2242
+ a message naming `SPFN_API_URL` or `authorizationServer.issuer`, whichever the value came
2243
+ from. An application with no `authorizationServer` block never reaches this check. The one
2244
+ value that is accepted and rewritten is a bare trailing slash: `https://api.acme.com/` is
2245
+ stored as `https://api.acme.com`, the form `@spfn/mcp` derives, so the two documents naming
2246
+ this server agree (RFC 8414 §3.3). That reduction happens where the config is resolved, not
2247
+ in the boot check, so a document read without the lifecycle hook publishes the same issuer.
2248
+ - **Unapproved client rows are swept.** Registration is unauthenticated by necessity, so
2249
+ `auth.oauth2.client-purge` (in `authJobRouter`, daily at 05:00) deletes clients older than a
2250
+ day that no user ever approved. One with a grant against it is never touched. Registration
2251
+ is also capped per IP two ways — a burst rate limit, and a cap on how many unapproved
2252
+ clients one address may have standing, which a rate limit cannot express.
2253
+ - **`/mcp` tokens are not sessions.** An access token issued here authorizes the MCP surface
2254
+ for the resource it names. It is not a user session and is not accepted by ordinary API
2255
+ routes.
2256
+
2257
+ ### The consent screen
2258
+
2259
+ The screen itself is one route file on the web app, at the path published as
2260
+ `authorization_endpoint`:
2261
+
2262
+ ```typescript
2263
+ // app/oauth/authorize/route.ts
2264
+ import { createOAuth2AuthorizeHandlers } from '@spfn/auth/nextjs/server';
2265
+
2266
+ export const { GET, POST } = createOAuth2AuthorizeHandlers({ loginPath: '/login' });
2267
+ ```
2268
+
2269
+ `GET` asks `GET /_auth/oauth2/authorize` what the request is and draws it; `POST` checks the
2270
+ form's own CSRF token, sends the decision to `POST /_auth/oauth2/authorize`, and redirects the
2271
+ browser back to the waiting CLI. Neither decides anything — the API validates the request from
2272
+ scratch both times, because the form between the two calls is in the user's browser.
2273
+
2274
+ | Option | What it is |
2275
+ | --- | --- |
2276
+ | `loginPath` | Where a visitor with no session goes. The handler appends `?returnUrl=` pointing at this request's own path and query, so signing in lands back on the screen with its parameters intact. The value is held to `isSafeReturnPath` like every other return destination in this package, and a refusal is a 400 screen rather than a redirect |
2277
+ | `render?` | `(view: OAuth2ConsentView) => string`, replacing the default body. Status, headers and the field set stay the handler's |
2278
+
2279
+ Every answer carries `Cache-Control: no-store`, and every page also carries
2280
+ `Content-Type: text/html; charset=utf-8` and `Content-Security-Policy: frame-ancestors 'none'`
2281
+ — a consent screen that can be framed is a consent screen that can be clickjacked.
2282
+
2283
+ - **The two refusal kinds become the two answers.** `unknown_client` and
2284
+ `redirect_uri_mismatch` are shown on a 400 screen with no `Location` at all. Every other
2285
+ refusal — `invalid_request`, `invalid_target`, `invalid_scope`, `access_denied` — is a 302 to
2286
+ the redirect URI **the API returned**, carrying `error=` and the `state` verbatim. The
2287
+ `redirect_uri` in the request is forwarded to the API and never built into a `Location`: the
2288
+ API's value is the one that matched a registration, which is the whole difference between a
2289
+ redirect and an open redirect.
2290
+ - **The POST carries its own CSRF token.** The page puts the readable CSRF cookie in a hidden
2291
+ `csrf` field and the POST refuses, before calling the API at all, unless the field matches
2292
+ the cookie. The handler's server-side call to the API mints the CSRF header itself and would
2293
+ always pass, so the form's token is the only check that means anything here.
2294
+ - **`render` owns the body and nothing else.** `OAuth2ConsentView` carries `clientName`,
2295
+ `redirectHost`, `scopes`, `resource`, the `fields` to echo as hidden inputs, and the
2296
+ `csrfToken`, all raw — put every one of them through the exported `escapeHtml`. `clientName`
2297
+ arrives from unauthenticated dynamic registration, and a renderer that drops `fields` or
2298
+ `csrfToken` produces a form the API refuses.
2299
+
2300
+ The end-to-end path — lifecycle config, this route, `/mcp`, and connecting from Claude Code
2301
+ and Codex — is [docs/guides/mcp-clients.md](../../docs/guides/mcp-clients.md).
2302
+
2303
+ ## Machine principals (`registerMachineVerifier`)
2304
+
2305
+ A machine credential is issued by a service to a non-interactive process, and its subject is
2306
+ an account or a tenant, not a person. `AuthContext` cannot hold one — it requires a `users`
2307
+ row — and resolving a machine token to its owning user is worse than the type error: it makes
2308
+ the machine's request indistinguishable from that user's own session.
2309
+
2310
+ So a machine principal never enters `AuthContext`. It lives in its own context key, is read by
2311
+ its own helper, and is admitted by its own middleware:
2312
+
2313
+ ```typescript
2314
+ import { machineAuth, requireMachineScope, getMachinePrincipal } from '@spfn/auth/server';
2315
+
2316
+ export const ingest = route.post('/v1/ingest')
2317
+ .use([machineAuth, requireMachineScope('events:write')])
2318
+ .handler(async (c) =>
2319
+ {
2320
+ const { subjectType, subjectId } = getMachinePrincipal(c.raw)!;
2321
+ // subjectType: 'account' | 'service' | whatever the verifier named
2322
+ });
2323
+ ```
2324
+
2325
+ `getAuth(c)` on that route returns nothing, because nothing put a user there. That is the
2326
+ whole design: a machine request cannot impersonate a user session, not because a check
2327
+ forbids it but because no code path leads there.
2328
+
2329
+ **Ownership is not authentication.** Who issued a machine token, who owns it, and who may
2330
+ revoke or audit it are the registrant's data-level concerns — put the token id in `claims` and
2331
+ answer them from your own tables. What the request *acts as* is the token's own subject and
2332
+ scopes, and nothing here resolves a machine subject to a user.
2333
+
2334
+ ### Registering a verifier
2335
+
2336
+ A verifier claims one namespace, by a raw `tokenPrefix` (for an opaque secret, the
2337
+ `spfn_ops_` shape) or by a `kidPrefix` on the unverified JOSE header of a JWS. The built-in
2338
+ ops token's own shape is exported rather than spelled out — match it with `isOpsToken` or
2339
+ `OPS_TOKEN_PREFIX` from `@spfn/auth/server`. Register at boot, before the first request:
2340
+
2341
+ ```typescript
2342
+ import { registerMachineVerifier } from '@spfn/auth/server';
2343
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
2344
+
2345
+ const RUNTIME_JWKS = createRemoteJWKSet(new URL('https://issuer.example.com/.well-known/jwks.json'));
2346
+
2347
+ registerMachineVerifier({
2348
+ id: 'runtimeJwsV1',
2349
+ match: { kidPrefix: 'machine:runtime:' },
2350
+ verify: async (token) =>
2351
+ {
2352
+ const { payload } = await jwtVerify(token, RUNTIME_JWKS, { issuer: 'https://issuer.example.com' });
2353
+
2354
+ return {
2355
+ subjectType: 'account',
2356
+ subjectId: String(payload.sub),
2357
+ scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
2358
+ claims: { tokenId: payload.jti },
2359
+ scheme: 'runtimeJwsV1',
2360
+ };
2361
+ },
2362
+ });
2363
+ ```
2364
+
2365
+ The request carries it as an ordinary bearer token — no new wire format, and the
2366
+ profile-header channel is not involved:
2367
+
2368
+ ```http
2369
+ POST /v1/ingest
2370
+ Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hY2hpbmU6cnVudGltZTo...
2371
+ ```
2372
+
2373
+ - **Namespace your kids.** `machine:` is the convention this package documents, and a user
2374
+ session JWT never carries that shape. The prefix is what tells the two apart before either
2375
+ is verified.
2376
+ - **Conflicting discriminators are refused at registration** — a duplicate `id`, a duplicate
2377
+ prefix, or a prefix that would shadow an already-registered one (`machine:` swallowing
2378
+ `machine:runtime:`). Two verifiers one token could match would make admission depend on
2379
+ registration order, so that is a boot-time error rather than something the dispatch
2380
+ resolves per request.
2381
+ - **A `tokenPrefix` claims every token that starts with it**, and `authenticate` consults the
2382
+ registry before it decodes anything. A prefix a user's JWT could begin with (`ey…`) would
2383
+ therefore refuse every user session — pick a prefix no other credential on your surface
2384
+ shares, as `spfn_ops_` does.
2385
+ - **Register at boot, before the first request.** The registry is module state read on every
2386
+ dispatch, so a verifier registered later is simply a verifier the requests before it did
2387
+ not have. There is no unregistration and no reset — the same contract, and the same reason,
2388
+ as [`registerAuthProfile`](#custom-auth-profiles-registerauthprofile).
2389
+ - **Registering nothing costs nothing.** With no verifier registered, `authenticate` is two
2390
+ array-length checks away from what it was. The unverified JOSE header peek happens only
2391
+ once a `kidPrefix` verifier exists.
2392
+ - **`scheme` is the registry's answer**, not the verifier's: whatever a verifier returns
2393
+ there, the principal carries the `id` that admitted it, so an audit trail cannot be made to
2394
+ name the wrong verifier.
2395
+
2396
+ ### The case table
2397
+
2398
+ | credential ↓ route → | `authenticate` (user) | `machineAuth` | `optionalAuth` |
2399
+ |---|---|---|---|
2400
+ | user bearer JWT | ✓ user (unchanged) | 401 | ✓ user (unchanged) |
2401
+ | machine token, registered namespace, valid | 401 — refused before the token is decoded | ✓ sets `machinePrincipal` | 401 |
2402
+ | machine token, registered namespace, verifier rejects | 401 | 401 | 401 |
2403
+ | machine-shaped token, unregistered namespace | 401 (the existing invalid-token path) | 401 | continues, no auth |
2404
+ | profile header + any Bearer | `PROFILE_REJECTED` (unchanged) | `PROFILE_REJECTED` | `PROFILE_REJECTED` |
2405
+ | nothing | 401 (unchanged) | 401 | continues, no auth |
2406
+ | valid principal, missing scope | — | 403 | — |
2407
+ | valid principal, sufficient scope | — | 200 | — |
2408
+
2409
+ Every 401 above is one message. Whether a namespace is registered, whether a presented token
2410
+ was ever valid, and whether a verifier rejected it are not inferable from the answer — the
2411
+ same non-disclosure rule the [ops-token](#ops-tokens-spfn-ops) table keeps. 403 is reserved
2412
+ for scope, where the caller is already authenticated; `requireMachineScope` matches scopes
2413
+ exactly and has no wildcard, and it fails closed with a 401 if it runs without `machineAuth`
2414
+ before it.
2415
+
2416
+ A verifier that throws something other than a refusal — a bug in registrant code — is the
2417
+ same generic 401 on the wire, with the real error logged. Never a 500 carrying registrant
2418
+ internals, and never a silent pass.
2419
+
2420
+ The last row of the unregistered-namespace case is the one asymmetry: a token in a namespace
2421
+ nobody registered is not a machine credential as far as this package can tell, so under
2422
+ `optionalAuth` it gets what any unusable bearer token has always got. A token in a
2423
+ *registered* namespace is refused there, because refusing it is the difference between
2424
+ "presented the wrong credential" and "presented none".
2425
+
2426
+ The non-disclosure above is therefore an `authenticate` and `machineAuth` property, not an
2427
+ `optionalAuth` one: on an `optionalAuth` route a caller can tell a registered namespace from
2428
+ an unregistered one, because one is refused and the other is served anonymously. Closing that
2429
+ gap would mean refusing every unusable bearer token on those routes — a change to behaviour
2430
+ that predates machine principals, and a worse trade than the inference it prevents. Mount
2431
+ `machineAuth` where the distinction matters.
2432
+
2433
+ ### Issuance is yours
2434
+
2435
+ This package verifies machine tokens; it does not mint them. Issuance, rotation, and
2436
+ revocation belong to whoever owns the subject — keep the tokens short-lived, and prefer a
2437
+ signature you can verify offline (`kidPrefix` + JWKS) over a secret you must look up.
2438
+
2439
+ `opsTokenAuth` is the built-in instance of exactly this pattern, hand-written for one
2440
+ credential before the registry existed: its own context key (`opsToken`), its own scope guard,
2441
+ `AuthContext` never set. It keeps its own implementation and is not registered here.
2442
+ A route that must admit an ops token *or* a user session uses
2443
+ [`opsOrUser`](#one-route-two-credentials-opsoruser), which composes the two existing
2444
+ middleware pairs behind one branch on credential shape rather than widening either path.
2445
+
1066
2446
  ## Account Deletion & Recovery
1067
2447
 
1068
2448
  Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
@@ -1117,7 +2497,7 @@ export default defineServerConfig()
1117
2497
  },
1118
2498
  },
1119
2499
  }))
1120
- .jobs(authJobRouter) // registers the daily (04:00 UTC) purge sweep
2500
+ .jobs(authJobRouter) // the daily (04:00 UTC) purge sweep, and auth.link-mail
1121
2501
  .routes(appRouter)
1122
2502
  .build();
1123
2503
  ```
@@ -1154,15 +2534,68 @@ is fixed at module-import time, which happens before `createAuthLifecycle()` run
1154
2534
  `createAuthLifecycle()` call, and register that instead:
1155
2535
 
1156
2536
  ```typescript
1157
- import { createAuthDeletionJobRouter } from '@spfn/auth/server';
2537
+ import { createAuthJobRouter } from '@spfn/auth/server';
1158
2538
 
1159
2539
  // ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
1160
- .jobs(createAuthDeletionJobRouter({ purgeCron: '0 3 * * *' }))
2540
+ .jobs(createAuthJobRouter({ purgeCron: '0 3 * * *' }))
1161
2541
  ```
1162
2542
 
1163
- Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)` — both build a job
1164
- named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
1165
- router) double-registers the same job name against pg-boss instead of overriding it.
2543
+ Register **only one** of `authJobRouter` / `createAuthJobRouter(...)` — both build the same job
2544
+ names, so registering both (e.g. the static export *and* a custom-cron router) double-registers
2545
+ each name against pg-boss instead of overriding it.
2546
+
2547
+ `createAuthDeletionJobRouter` is the former name of `createAuthJobRouter` and still works, with
2548
+ the same argument and the same result. It is deprecated because the router has carried more than
2549
+ the deletion purge since `auth.link-mail` joined it.
2550
+
2551
+ ### Link mail delivery
2552
+
2553
+ `auth.link-mail` is the second job on the router, and the reason to register the router even in
2554
+ an app that never deletes an account.
2555
+
2556
+ **What it queues, and why only a row id.** Three mails leave through it: the verified-email
2557
+ signup link, the password reset link, and the "you already have an account" notice the signup
2558
+ request answers a known address with. The payload is `{ kind, rowId }` — or `{ kind, target,
2559
+ targetType }` for the notice — and never the token, the URL or the rendered mail.
2560
+ `@spfn/notification` can queue a send of its own, but its payload carries the *rendered* mail,
2561
+ which for these three templates would leave the link token in plaintext in `pgboss.job` until
2562
+ archive. So the queue carries a reference and the worker mints the credential moments before
2563
+ sending it: the plaintext exists in the mail and nowhere else.
2564
+
2565
+ **What that buys.** The request writes its row with `token_hash` null and answers. Both branches
2566
+ of both endpoints now cost the same database work, so how long a request took no longer says
2567
+ whether the address has an account — the mail was the only asymmetry left. A pending row is not
2568
+ confirmable: a null hash matches no lookup, and the worker's `issue` refuses a row that was
2569
+ superseded, consumed, completed or expired in the meantime, in the same statement that would
2570
+ write the hash. A failed send throws so pg-boss retries, and the retry re-mints, which is why a
2571
+ token from a failed attempt stops working.
2572
+
2573
+ **The three modes** — `SPFN_AUTH_LINK_MAIL_DELIVERY`:
2574
+
2575
+ | mode | behaviour |
2576
+ |------|-----------|
2577
+ | `auto` (default) | queue when pg-boss is initialised, send on the request when it is not — an app with no jobs keeps working exactly as before |
2578
+ | `queued` | always queue; an enqueue failure surfaces as a failed request rather than becoming an inline send |
2579
+ | `inline` | always send on the request — today's behaviour, and the timing signal that comes with it |
2580
+
2581
+ **When the provider refuses on the request path** — `inline`, `auto` with no pg-boss, or the
2582
+ fallback below — the failure is logged and the request still answers as if the mail had gone out,
2583
+ because an answer that depended on the mail provider would be an account-existence oracle during
2584
+ an outage; the user asks again, and only the job path retries.
2585
+
2586
+ **The fallback warning.** In `auto`, an app that initialised pg-boss but never registered this
2587
+ router has no `auth.link-mail` queue, so the enqueue fails. Losing the mail there would be silent,
2588
+ so that request sends inline instead and the log says once per process:
2589
+
2590
+ ```
2591
+ Queue auth.link-mail does not exist, so this link mail was sent on the request path.
2592
+ Register the auth job router — .jobs(authJobRouter) — or set SPFN_AUTH_LINK_MAIL_DELIVERY='inline'.
2593
+ ```
2594
+
2595
+ The fix is in the message: register the router, or say `inline` if sending on the request is what
2596
+ you want. Only a missing queue falls back — every other enqueue failure, a database outage above
2597
+ all, surfaces, because falling back on those would hide the outage behind mail that still gets
2598
+ through.
1166
2599
 
1167
2600
  ## FAQ
1168
2601
 
@@ -1176,9 +2609,20 @@ deploy.
1176
2609
  Almost always the callback origin. The CSRF check is a double-submit against a host-only
1177
2610
  cookie set on your **web app** host, so the provider must return to the web app origin, and
1178
2611
  the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
1179
- callback 404s — including in local dev. Details in
2612
+ callback 404s — including in local dev. An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` on the
2613
+ wrong origin or path no longer gets that far: it fails at boot with a message naming the
2614
+ variable. Details in
1180
2615
  [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
1181
2616
 
2617
+ **I forgot my password.**
2618
+ Send the address to `requestPasswordReset` and open the link that arrives. Any `active`
2619
+ account whose email is verified — or that already has a password, which covers every account
2620
+ created before the column was stamped — can be reset that way. See
2621
+ [Password reset](#password-reset-verified-email). Completing it signs every other device out,
2622
+ so it is also the answer to "someone else knows my password". An account with neither a
2623
+ verified address nor a password (OAuth-only, provider said unverified) cannot be reset by
2624
+ email; it signs in through its provider.
2625
+
1182
2626
  **Does the server hold my users' private keys?**
1183
2627
  No. The client generates an ES256/RS256 keypair, sends only the public key on register or
1184
2628
  login, and signs each request itself. The server verifies with the stored public key. Keys
@@ -1208,6 +2652,23 @@ authorization.
1208
2652
  The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
1209
2653
  verified, active, and required to change their password on first login.
1210
2654
 
2655
+ **Is `Foo@Example.com` the same account as `foo@example.com`?**
2656
+ Yes. Addresses are trimmed and lower-cased on the way in and on the way out, so one person
2657
+ who capitalizes differently on different days reaches one account instead of creating a
2658
+ second. Nothing else is folded — Gmail's dot and `+` rules are that provider's delivery
2659
+ behaviour, not an internet rule, and applying them would merge addresses other providers
2660
+ treat as different people.
2661
+
2662
+ `createAuthLifecycle()` brings existing rows into the same form on startup. If two accounts
2663
+ differ only by capitalization, both are left exactly as they are and their user ids are
2664
+ logged as an error: which one is the real account, and what becomes of the other's data, is
2665
+ not a question the package can answer for you. Until you resolve it, the mixed-case one
2666
+ cannot sign in.
2667
+
2668
+ Admin seeding is unaffected either way. It recognizes a configured admin in whatever form
2669
+ the address was stored, so an account the backfill has not reached is skipped rather than
2670
+ duplicated into a second privileged row holding the configured password.
2671
+
1211
2672
  ## Pitfalls & anti-patterns
1212
2673
 
1213
2674
  - **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
@@ -1245,8 +2706,10 @@ verified, active, and required to change their password on first login.
1245
2706
  '@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
1246
2707
  - **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
1247
2708
  hook runs *before* `@spfn/core` initializes pg-boss and registers jobs, so the lifecycle has no
1248
- opportunity to auto-register the account-deletion purge job. Call `.jobs(authJobRouter)`
1249
- yourself — see [Account Deletion & Recovery](#account-deletion--recovery).
2709
+ opportunity to auto-register the jobs. Call `.jobs(authJobRouter)` yourself — see
2710
+ [Account Deletion & Recovery](#account-deletion--recovery). An app that initialises pg-boss and
2711
+ skips this keeps sending link mail, but on the request path, with a warning naming the router —
2712
+ see [Link mail delivery](#link-mail-delivery).
1250
2713
  - **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
1251
2714
  or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
1252
2715
  so nothing enforces this at the database layer.