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

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 (43) hide show
  1. package/README.md +1382 -23
  2. package/dist/client-proof.d.ts +45 -15
  3. package/dist/client-proof.js +198 -4
  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 +302 -0
  9. package/dist/config.js +134 -4
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +370 -3
  12. package/dist/errors.js +245 -2
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +185 -2
  15. package/dist/index.js +256 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/machine-principals-BD4tnASp.d.ts +2739 -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 +3761 -414
  27. package/dist/server.js +5865 -1043
  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/package.json +9 -6
  43. 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,14 @@ 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_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) |
169
+ | `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) |
170
+ | `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | — | `preferred` (default) or `required`; `discouraged` refuses boot |
171
+ | `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` / `_RECENT_AUTH_MINUTES` | `.env.server` | — | defaults `300` / `10` — see [Passkeys](#passkeys-webauthn) |
157
172
  | `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
158
173
 
159
174
  Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
@@ -178,7 +193,25 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
178
193
  | `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
179
194
  | `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
180
195
  | `register` | POST `/_auth/register` | public | create user + register public key |
196
+ | `requestSignupLink` | POST `/_auth/signup/email` | public | email a one-time signup confirmation link — see [Verified-email signup](#verified-email-signup) |
197
+ | `confirmSignupLink` | POST `/_auth/signup/email/confirm` | public | exchange the link for a password-setup session |
198
+ | `completeSignup` | POST `/_auth/signup/password` | setup session | set the password, which creates the account and signs in |
199
+ | `requestPasswordReset` | POST `/_auth/password/reset` | public | email a one-time password reset link — see [Password reset](#password-reset-verified-email) |
200
+ | `confirmPasswordReset` | POST `/_auth/password/reset/confirm` | public | exchange the link for a password-setup session |
201
+ | `completePasswordReset` | POST `/_auth/password/reset/complete` | setup session | set the new password, sign every other device out, sign this one in |
181
202
  | `login` | POST `/_auth/login` | public | password login + new session key |
203
+ | `startDeviceAuth` | POST `/_auth/device/start` | public | begin a device-code login — see [Device-code login](#device-code-login) |
204
+ | `pollDeviceAuth` | POST `/_auth/device/poll` | public | ask whether the request was answered; the approved answer *is* the login |
205
+ | `getDeviceAuthInfo` | POST `/_auth/device/info` | yes | what device is asking, so the approval screen can show it |
206
+ | `approveDeviceAuth` | POST `/_auth/device/approve` | yes | let the waiting device in |
207
+ | `denyDeviceAuth` | POST `/_auth/device/deny` | yes | refuse it |
208
+ | `passkeyRegisterOptions` | POST `/_auth/passkeys/register/options` | yes | begin enrolling a passkey — see [Passkeys](#passkeys-webauthn) |
209
+ | `passkeyRegisterVerify` | POST `/_auth/passkeys/register/verify` | yes | verify the attestation and keep the credential |
210
+ | `passkeyLoginOptions` | POST `/_auth/passkeys/login/options` | public | begin a passkey sign-in; takes no identifier |
211
+ | `passkeyLoginVerify` | POST `/_auth/passkeys/login/verify` | public | verify the assertion; answers exactly as `login` |
212
+ | `listPasskeys` | POST `/_auth/passkeys/list` | yes | the caller's enrolled passkeys |
213
+ | `renamePasskey` | POST `/_auth/passkeys/rename` | yes | rename one |
214
+ | `revokePasskey` | POST `/_auth/passkeys/revoke` | yes | retire one (refused if it is the last way in) |
182
215
  | `logout` | POST `/_auth/logout` | yes | revoke current key |
183
216
  | `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
184
217
  | `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
@@ -194,6 +227,7 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
194
227
  | `cancelAccountDeletion` | POST `/_auth/deletion/cancel` | public | cancel a pending deletion (credential-based recovery) |
195
228
  | `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
196
229
  | OAuth routes | — | — | see OAuth section |
230
+ | `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
231
 
198
232
  There is deliberately **no account-existence endpoint**. `POST /_auth/exists` was removed
199
233
  because it answered "does this account exist" directly, which is user enumeration; the
@@ -205,8 +239,280 @@ sends the public key on register/login, signs request JWTs locally, and the serv
205
239
  with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
206
240
  Keys expire after 90 days — rotate with `rotateKey`.
207
241
 
242
+ ### Verified-email signup
243
+
244
+ A second way in, alongside the six-digit code. The address is proven before a password
245
+ exists, so nothing is stored for someone who never confirms.
246
+
247
+ ```
248
+ request → a one-time link is emailed
249
+ confirm → the link becomes a short-lived, HttpOnly password-setup session
250
+ password → the account is created, the device registered, the user signed in
251
+ ```
252
+
253
+ The six-digit-code path (`sendVerificationCode` → `verifyCode` → `register`) is unchanged.
254
+ Offer whichever suits your product, or both.
255
+
256
+ **1 — request the link.** The response is identical whether or not the address already has
257
+ an account, so it cannot be used to probe for accounts. When one exists, the owner gets a
258
+ "you already have an account" notice instead of a usable link.
259
+
260
+ ```typescript
261
+ await authApi.requestSignupLink.call({
262
+ body: { email: 'user@example.com', returnPath: '/welcome' }, // returnPath optional
263
+ });
264
+ // → { success: true, expiresAt }
265
+ ```
266
+
267
+ Calling it again is how a resend works: it invalidates the previous link and any setup
268
+ session opened from it. `returnPath` must be a path inside your app — absolute URLs,
269
+ `//host`, and `..` are refused, so the link cannot become an open redirect.
270
+
271
+ The mail leaves through the `auth.link-mail` job when pg-boss is initialised — register
272
+ `authJobRouter` — so neither branch of this endpoint waits on a mail provider; see
273
+ [Link mail delivery](#link-mail-delivery).
274
+
275
+ **2 — the page the link opens.** The email points at a page in *your* app
276
+ (`SPFN_AUTH_SIGNUP_CONFIRM_PATH`, default `/signup/confirm`), not at an API route. That page
277
+ reads the token from the query string and posts it:
278
+
279
+ ```typescript
280
+ 'use client';
281
+
282
+ const token = useSearchParams().get('token');
283
+
284
+ const { email, returnPath } = await authApi.confirmSignupLink.call({ body: { token } });
285
+
286
+ // Drop the token from the URL so it does not linger in history or a Referer header.
287
+ window.history.replaceState({}, '', window.location.pathname);
288
+ ```
289
+
290
+ The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
291
+ and strips it from the response body, so page script never holds it. Serve this page with
292
+ `Referrer-Policy: no-referrer`.
293
+
294
+ **3 — set the password.** This is the step that creates the account. The setup cookie
295
+ authorizes it; the device keypair is injected by the interceptor exactly as it is for
296
+ `register`.
297
+
298
+ ```typescript
299
+ await authApi.completeSignup.call({ body: { password } });
300
+ // → { userId, publicId, email } + session cookie, same as register
301
+ ```
302
+
303
+ Creating the user, registering the device key, and marking the setup session used all commit
304
+ together. A password that fails the strength policy leaves the session usable, so the user
305
+ retypes rather than requesting a fresh email.
306
+
307
+ **Settings.**
308
+
309
+ | Variable | Default | Meaning |
310
+ |----------|---------|---------|
311
+ | `SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
312
+ | `SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
313
+ | `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `/signup/confirm` | the page in your app the link opens |
314
+
315
+ The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
316
+ OAuth callbacks use. Delivery uses the `signup-link` template in `@spfn/notification` —
317
+ override it there to change the copy.
318
+
319
+ **What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
320
+ `spfn_auth.signup_link_tokens`. Neither credential is recoverable from the database, and
321
+ both are one-time: a link opens one setup session, and a setup session sets one password.
322
+
323
+ ### Password reset (verified email)
324
+
325
+ The way back into an account whose password is gone, using the address the account already
326
+ proved. Same three steps as the signup above, and the same posture on the two credentials.
327
+
328
+ ```
329
+ request → a one-time link is emailed
330
+ confirm → the link becomes a short-lived, HttpOnly password-setup session
331
+ complete → the new password is written, every other device is signed out, this one is signed in
332
+ ```
333
+
334
+ **Who can reset.** An `active` account whose `emailVerifiedAt` is set **or** that already has
335
+ a password. The second half is what makes the rule work on accounts created before the
336
+ column was stamped: both register paths proved the address at signup. An OAuth-only account
337
+ whose provider reported the address unverified has neither and is excluded — for it, a reset
338
+ would be a way in built on an address nobody proved.
339
+
340
+ **1 — request the link.** The response is identical for every input — same status, same two
341
+ fields, same `expiresAt` arithmetic — and mail goes only to an account that can be reset, so
342
+ neither the answer nor the mailbox reveals whether an address has an account here.
343
+
344
+ ```typescript
345
+ await authApi.requestPasswordReset.call({
346
+ body: { email: 'user@example.com', returnPath: '/account' }, // returnPath optional
347
+ });
348
+ // → { success: true, expiresAt }
349
+ ```
350
+
351
+ Calling it again is how a resend works: it invalidates the previous link and any setup
352
+ session opened from it. `returnPath` must be a path inside your app — absolute URLs,
353
+ `//host`, and `..` are refused, so the link cannot become an open redirect.
354
+
355
+ The mail leaves through the `auth.link-mail` job when pg-boss is initialised — register
356
+ `authJobRouter` — so an address with an account and one without cost the same; see
357
+ [Link mail delivery](#link-mail-delivery).
358
+
359
+ **2 — the page the link opens.** The email points at a page in *your* app
360
+ (`SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH`, default `/password/reset`), not at an API route.
361
+ That page reads the token from the query string and posts it:
362
+
363
+ ```typescript
364
+ 'use client';
365
+
366
+ const token = useSearchParams().get('token');
367
+
368
+ const { email, returnPath } = await authApi.confirmPasswordReset.call({ body: { token } });
369
+
370
+ // Drop the token from the URL so it does not linger in history or a Referer header.
371
+ window.history.replaceState({}, '', window.location.pathname);
372
+ ```
373
+
374
+ The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
375
+ and strips it from the response body, so page script never holds it. It is a cookie of its
376
+ own, not the signup one, so neither secret is ever accepted by the other flow. Serve this
377
+ page with `Referrer-Policy: no-referrer`.
378
+
379
+ **3 — set the new password.** The setup cookie authorizes it; the device keypair is injected
380
+ by the interceptor exactly as it is for `login`.
381
+
382
+ ```typescript
383
+ await authApi.completePasswordReset.call({ body: { password } });
384
+ // → { userId, publicId, email } + session cookie, same as login
385
+ ```
386
+
387
+ **Every other device is signed out.** Completing a reset denies every pending device
388
+ authorization and revokes every active key, exactly as `changePassword` does — whoever was
389
+ signed in on the old password, including the person the reset was needed for, has to sign in
390
+ again. The browser that performed the reset is signed in on a fresh key registered after the
391
+ revocation, so it does not have to retype the new password. `emailVerifiedAt` is stamped if
392
+ it was not already, `passwordChangeRequired` is cleared, and `auth.password.reset` is emitted
393
+ after commit.
394
+
395
+ The new hash, the revocations, the new device key and the completion mark commit together. A
396
+ password that fails the strength policy leaves the session usable, so the user retypes rather
397
+ than requesting a fresh email.
398
+
399
+ **Settings.**
400
+
401
+ | Variable | Default | Meaning |
402
+ |----------|---------|---------|
403
+ | `SPFN_AUTH_PASSWORD_RESET_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
404
+ | `SPFN_AUTH_PASSWORD_RESET_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
405
+ | `SPFN_AUTH_PASSWORD_RESET_CONFIRM_PATH` | `/password/reset` | the page in your app the link opens |
406
+
407
+ The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
408
+ signup link uses. Delivery uses the `password-reset` template in `@spfn/notification` —
409
+ override it there to change the copy.
410
+
411
+ **What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
412
+ `spfn_auth.password_reset_tokens`. Neither credential is recoverable from the database, and
413
+ both are one-time: a link opens one setup session, and a setup session sets one password.
414
+ A separate table from `signup_link_tokens`, so a signup secret can never address a reset row.
415
+
416
+ ### Device-code login
417
+
418
+ A way in for a device that has a screen but no comfortable keyboard — a TV, a console, a CLI
419
+ on a headless box. The new device shows a short code; the account owner types that code on a
420
+ device that is already signed in.
421
+
422
+ ```typescript
423
+ // On the new device — it has no key on file, so this call is public.
424
+ const { deviceCode, userCode, expiresAtMillis, intervalMillis } =
425
+ await authApi.startDeviceAuth.call({ body: {
426
+ publicKey, keyId, fingerprint, algorithm: 'ES256',
427
+ deviceName: 'Living room TV', platform: 'desktop',
428
+ } });
429
+
430
+ // Show `userCode` (XXXX-XXXX) on this device's screen, then poll every intervalMillis.
431
+ const answer = await authApi.pollDeviceAuth.call({ body: { deviceCode } });
432
+ // → { status: 'pending', intervalMillis }
433
+ // → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }
434
+ ```
435
+
436
+ ```typescript
437
+ // On the signed-in device — the user typed the code they read off the other screen.
438
+ const asking = await authApi.getDeviceAuthInfo.call({ body: { userCode } });
439
+ // → { deviceName?, platform?, fingerprintPrefix, requestedAtMillis, expiresAtMillis }
440
+
441
+ await authApi.approveDeviceAuth.call({ body: { userCode } }); // or denyDeviceAuth
442
+ ```
443
+
444
+ **There is no token handed over, because there is no token.** Every request in this system is
445
+ signed by the calling device's own key, so "logging a device in" means getting its public key
446
+ into `user_public_keys` under the right account — which is exactly what the winning poll does.
447
+ That is why the approved answer is the same shape `login` returns: from the client's side the
448
+ two ways in are indistinguishable.
449
+
450
+ - **Only ever show the code on the new device's screen.** The whole attack on this flow is
451
+ someone sending a victim a code and asking them to approve it — a support call, a chat
452
+ message, a "verify your account" email. A code that arrived any way other than off the
453
+ device in front of you is an attack. This is why `info` and `approve` answer with the
454
+ requesting device's name, platform and fingerprint prefix, and why an approval screen that
455
+ shows only the code is doing it wrong: it is asking the user to confirm a number they were
456
+ just told.
457
+ - **The device code is stored only as a SHA-256 hash**, like the ops-token and signup-link
458
+ secrets. It is returned once. A dump of `spfn_auth.device_authorizations` does not let its
459
+ reader finish anyone's login.
460
+ - **The user code is stored in the clear, and that is fine** — it authorizes nothing without
461
+ an approver who is already signed in. It is drawn from an alphabet with no `0`/`O` or
462
+ `1`/`I`/`L`, since it is read off one screen and typed on another.
463
+ - **A decision is made once.** Approve and deny move the record from `pending` and nowhere
464
+ else, so a second approval, a deny after an approve, or two approvals racing each other all
465
+ get `DeviceAuthAlreadyHandledError` (409) — a refusal is never undone.
466
+ - **The approval is one-shot.** The poll that registers the key spends the record in the same
467
+ statement that reads it, so of two polls arriving together exactly one registers the key and
468
+ the other is answered as if the code were unknown.
469
+ - **A spent code and a code that never existed answer identically** (`DeviceAuthNotFoundError`,
470
+ 404). Saying "that one was real, but it is used up" is the difference between guessing at
471
+ random and knowing a guess landed. Every route that accepts a code is rate limited for the
472
+ same reason: `start` and `poll` per IP, `info` / `approve` / `deny` per IP *and* per calling
473
+ account.
474
+ - **Expiry outranks state.** A code that sat past its TTL is expired whatever it says, so an
475
+ approval nobody collected in time registers nothing. The TTL travels in the statement that
476
+ moves the record, not only in the read before it, so a code cannot be spent by a poll that
477
+ read it a moment before it died.
478
+ - **A global revocation reaches the codes too.** `revoke-all`, a password change and a
479
+ deletion request each refuse the account's live device authorizations, so an approval nobody
480
+ collected cannot register a fresh key seconds after the user signed everything out — which
481
+ would hand one back to exactly the device they were cutting off. Revoking a single key,
482
+ logging out and rotating a key do not: those name one device, and the waiting one is not it.
483
+ - **The poll re-checks the account.** It is a login, so it refuses a suspended or
484
+ pending-deletion account with the same errors `/_auth/login` does. Approval and collection
485
+ are separate moments, and what the account is when the key is registered is what counts.
486
+ - **`start` bounds what it stores.** It is the one route that takes key material from a caller
487
+ who cannot authenticate, so `publicKey`, `keyId` and `fingerprint` carry length limits —
488
+ generous next to a real key (an RSA-2048 SPKI is 392 base64 characters against a 2048 limit)
489
+ and small next to the megabyte that would otherwise sit in a table no job clears.
490
+ - **Clock skew cannot affect this.** Every timestamp in the decision is the server's. The
491
+ `expiresAtMillis` in the start response is for the waiting device's countdown display, and
492
+ nothing the client believes about the time reaches the server's judgement.
493
+
494
+ Two knobs, both announced to the waiting device in the start response and therefore resolved
495
+ at lifecycle time rather than read per call:
496
+
497
+ ```typescript
498
+ createAuthLifecycle({
499
+ deviceAuth: {
500
+ ttlMs: 10 * 60 * 1000, // how long a code lives. default 10 minutes
501
+ intervalMs: 5000, // poll interval the server asks for. default 5s
502
+ },
503
+ })
504
+ ```
505
+
506
+ No job sweeps the table. Rows are judged by `expiresAt` whenever they are read or moved, so a
507
+ stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
508
+ not run out.
509
+
208
510
  ### Registered devices (key management)
209
511
 
512
+ A [passkey](#passkeys-webauthn) is **not** one of these keys: it is a credential that proves
513
+ identity at sign-in, after which an ordinary device key is registered exactly as a password
514
+ login registers one.
515
+
210
516
  Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
211
517
  `listKeys` / `revokeKey` / `revokeAllKeys` are what let the account owner see what accumulated and
212
518
  cut off anything they no longer recognise.
@@ -236,13 +542,19 @@ await authApi.revokeAllKeys.call({ body: {} }); // other devices o
236
542
  await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
237
543
  ```
238
544
 
239
- > **All three are POST with their arguments in the body, deliberately.** The mobile auth
545
+ > **All three key-management operations are POST with their arguments in the body, deliberately.** The mobile auth
240
546
  > profile (clientProofV1) signs the request body, and `canonical-json` fixes exactly how those
241
547
  > bytes are written. A `GET` has no body to sign, and a value in the path has no such rule —
242
548
  > client and server could disagree on the signed string over percent-encoding, a trailing
243
549
  > 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
-
550
+ > naming the cause. Proof-bearing auth operations are shaped this way; the unproven,
551
+ > bodyless `core.time` synchronization prerequisite is the explicit exception.
552
+
553
+ - **A key must be the type its algorithm names.** A P-256 SPKI declared `RS256`, an RSA key
554
+ declared `ES256`, and a curve other than P-256 declared `ES256` are each refused 400 with
555
+ `KeyAlgorithmMismatchError` on register, login, rotate and device start — the algorithm is
556
+ stored beside the key and read back at proof verification, so a mismatch accepted at
557
+ enrollment would surface only once the device already believed it was enrolled.
246
558
  - **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
247
559
  The list exists to recognise a device and point at it; the full fingerprint is what a native
248
560
  sign-in sends as its nonce, not a label.
@@ -254,6 +566,10 @@ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // every
254
566
  - **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
255
567
  "sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
256
568
  only as a side effect of changing a password, which nobody does for that reason.
569
+ - **It also refuses device-code approvals still in flight**, in both modes, because an approved
570
+ code is a key that has not been handed out yet: the next poll would register a fresh active one
571
+ and undo the sign-out. `revokedCount` still counts keys only — a code nobody collected was
572
+ never a session. See [Device-code login](#device-code-login).
257
573
  - **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
258
574
  the answer is only ever "not yours" and reveals nothing about other accounts.
259
575
  - **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
@@ -278,6 +594,280 @@ is still active is the one
278
594
  exception: it stays a no-op success, so repeated logins from the same device keep working, and an
279
595
  expired-but-active key has its expiry extended by the sign-in that proved the identity again.
280
596
 
597
+ ### Passkeys (WebAuthn)
598
+
599
+ A passkey is an **optional additional credential** on an account, alongside a password and a
600
+ linked social account rather than in place of either. Enroll one from a session that already
601
+ exists; sign in with it afterwards without typing an identifier at all.
602
+
603
+ ```
604
+ enroll → register/options (session) → the browser mints a credential → register/verify
605
+ sign in → login/options (public) → the browser picks a credential → login/verify
606
+ manage → list / rename / revoke
607
+ ```
608
+
609
+ **A passkey is not a device key.** The assertion proves *who* is asking; the device key the
610
+ Next.js proxy registers right after it is what every later request is signed with, exactly as
611
+ after a password login. Nothing in clientProofV1, in the JWT path, or in
612
+ [Registered devices](#registered-devices-key-management) changes because a session started
613
+ this way — a passkey sign-in produces the same `LoginResult` and the same key row as `login`.
614
+
615
+ #### Setup
616
+
617
+ ```bash
618
+ # .env.server — nothing is required; these are the overrides
619
+ SPFN_AUTH_PASSKEY_RP_ID=example.com
620
+ SPFN_AUTH_PASSKEY_ORIGINS=https://app.example.com,https://admin.example.com
621
+ ```
622
+
623
+ With neither set, the relying party is derived from `{NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}`:
624
+ its host becomes the rpId and its origin becomes the single allowed origin. That is the whole
625
+ configuration for a one-origin app.
626
+
627
+ | Var | File | Notes |
628
+ |-----|------|-------|
629
+ | `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** |
630
+ | `SPFN_AUTH_PASSKEY_RP_NAME` | `.env.server` | name the authenticator's own prompt shows. Default: the rpId |
631
+ | `SPFN_AUTH_PASSKEY_ORIGINS` | `.env.server` | comma-separated full origins allowed to run a ceremony. Default: the app URL's origin |
632
+ | `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | `preferred` (default) or `required`. `discouraged` refuses boot |
633
+ | `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` | `.env.server` | default `300` — one ceremony at the authenticator, not an abandoned tab |
634
+ | `SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES` | `.env.server` | default `10` — see [the recent-authentication gate](#the-recent-authentication-gate) |
635
+
636
+ Two rules on those origins, **checked at boot** and refused with `PasskeyConfigError`:
637
+
638
+ - each origin must be `https`, and `localhost` is the one host a browser treats as a secure
639
+ context over plain `http` — so `http://localhost:3000` is legal and `http://app.example.com`
640
+ is not;
641
+ - each origin's host must be the rpId or a subdomain of it, because the browser will refuse
642
+ the ceremony otherwise.
643
+
644
+ The check runs at `initializeAuth`, deliberately: every one of these values makes *every*
645
+ passkey operation fail, the drift is between environments, and the deploy that introduces it
646
+ is where it has to surface — not the first sign-in after it.
647
+
648
+ **Boot is only refused for a configuration you wrote.** If no `SPFN_AUTH_PASSKEY_*` variable
649
+ is set, the derived relying party can still be unusable — `SPFN_APP_URL=http://192.168.1.5:3000`
650
+ so a phone on the same network can reach your laptop, say, which is neither https nor
651
+ localhost. Refusing to start over a feature nobody asked for would take that app down to fix
652
+ something it does not use, so it is logged once instead and only a ceremony fails. Set any
653
+ passkey variable and the same configuration refuses to start. This is the posture
654
+ [the OAuth callback origin check](#oauth-callback-origin-web-app-host--rewrite) already takes.
655
+
656
+ #### Enrolling, from a Next.js client component
657
+
658
+ ```tsx
659
+ 'use client';
660
+ import { authApi } from '@spfn/auth';
661
+ import { enrollPasskey, isPasskeySupported } from '@spfn/auth/client';
662
+
663
+ async function addPasskey()
664
+ {
665
+ const result = await enrollPasskey(authApi, { label: 'MacBook Touch ID' });
666
+
667
+ if (!result.ok)
668
+ {
669
+ // 'unsupported' | 'cancelled' | 'error' — 'cancelled' is not an error to show
670
+ return result.reason === 'cancelled' ? undefined : showError(result.reason);
671
+ }
672
+
673
+ showAdded(result.passkeyId, result.label);
674
+ }
675
+ ```
676
+
677
+ `isPasskeySupported()` is what decides whether to render the button at all.
678
+
679
+ #### Signing in, with conditional UI
680
+
681
+ The passkey appears in the browser's ordinary autofill dropdown. That needs an input whose
682
+ `autocomplete` ends in `webauthn`, and a `signInWithPasskey` call started **when the form
683
+ renders**, not on a click:
684
+
685
+ ```tsx
686
+ 'use client';
687
+ import { useEffect } from 'react';
688
+ import { authApi } from '@spfn/auth';
689
+ import { isConditionalMediationAvailable, signInWithPasskey } from '@spfn/auth/client';
690
+
691
+ export function SignInForm()
692
+ {
693
+ useEffect(() =>
694
+ {
695
+ void (async () =>
696
+ {
697
+ if (!await isConditionalMediationAvailable()) return;
698
+
699
+ const result = await signInWithPasskey(authApi, { conditional: true });
700
+ if (result.ok) router.replace('/');
701
+ })();
702
+ }, []);
703
+
704
+ return (
705
+ <form>
706
+ <input name="email" autoComplete="username webauthn" />
707
+ <input name="password" type="password" autoComplete="current-password" />
708
+ </form>
709
+ );
710
+ }
711
+ ```
712
+
713
+ Where conditional mediation is missing, render a visible "Sign in with a passkey" button that
714
+ calls `signInWithPasskey(authApi)` instead.
715
+
716
+ Both helpers answer with a discriminated union and **never throw a cancellation**: a person
717
+ who dismisses the system sheet raises `NotAllowedError`, and so does a person whose
718
+ authenticator had nothing to offer — neither is an application error, and code that has to
719
+ tell them apart by re-reading `error.name` gets it wrong once and shows a red banner to
720
+ someone who simply changed their mind.
721
+
722
+ | result | meaning |
723
+ |--------|---------|
724
+ | `{ ok: true, ... }` | signed in / enrolled; the rest of the object is the server's answer |
725
+ | `{ ok: false, reason: 'unsupported' }` | this browser has no WebAuthn; nothing was sent to the server |
726
+ | `{ ok: false, reason: 'cancelled' }` | the person dismissed the enrollment prompt |
727
+ | `{ ok: false, reason: 'no-credential' }` | sign-in: the authenticator offered nothing, or the person dismissed it |
728
+ | `{ ok: false, reason: 'error', error }` | anything else, with the original error attached |
729
+
730
+ #### The recent-authentication gate
731
+
732
+ Adding a credential is adding a way in, and removing one can lock an account. Both are refused
733
+ unless the caller has recently proved themselves, in one of two ways:
734
+
735
+ - **the device key this request is signed with was registered within
736
+ `SPFN_AUTH_PASSKEY_RECENT_AUTH_MINUTES`** — that is when this device last presented a
737
+ credential, and it needs no new state; or
738
+ - **the body carries `currentPassword`** and it verifies.
739
+
740
+ Otherwise: **403 with `code: 'RECENT_AUTH_REQUIRED'`**. Branch on that code to prompt for the
741
+ password and retry — it is a stable field, not a message to match on.
742
+
743
+ An account with no password stored **cannot** satisfy the gate with a password, however
744
+ plausible the value; it has to sign in again. The comparison still runs, against a dummy hash,
745
+ so "no password on file" costs exactly what "wrong password" costs — otherwise response time
746
+ becomes an oracle for which accounts are OAuth-only.
747
+
748
+ #### Managing passkeys
749
+
750
+ ```typescript
751
+ const { passkeys } = await authApi.listPasskeys.call({ body: {} });
752
+ // → [{ passkeyId, label, deviceType, backedUp, transports, createdAt, lastUsedAt }]
753
+
754
+ await authApi.renamePasskey.call({ body: { passkeyId, label: 'Old iPhone' } });
755
+ await authApi.revokePasskey.call({ body: { passkeyId } });
756
+ ```
757
+
758
+ - **Neither `credentialId` nor the public key is ever returned.** They are what an
759
+ authenticator is addressed by; the list exists to let someone recognise a credential and
760
+ point at it, which the label, the device type and the last-used moment do.
761
+ - **`deviceType` is `singleDevice` or `multiDevice`**, and `backedUp` says whether a
762
+ multi-device credential actually has been. "This one only exists on that phone" is what the
763
+ owner needs before revoking the other entry.
764
+ - **Revocation is soft, and the credential id stays reserved for good.** A credential someone
765
+ cut off can never be enrolled again — not on another account, and not on the same one
766
+ (`PasskeyAlreadyRegisteredError`, 409). Re-enrolling means a fresh credential.
767
+ - **A passkey id you do not own answers 404.** Every lookup is owner-scoped, so the answer is
768
+ only ever "not yours".
769
+ - **Renaming has no recent-authentication gate**: a label is display only and nothing is
770
+ authorized by it.
771
+
772
+ #### Recovery — read this before shipping a passkey-only sign-up
773
+
774
+ The ways back into an account are: a live passkey, a password, a linked social account, or a
775
+ verified email address — the last one because [Password reset](#password-reset-verified-email)
776
+ can always give such an account a password back. Nothing else; support cannot restore an
777
+ account that has none of the four.
778
+
779
+ That is why **revoking the last live passkey is refused (409, `code:
780
+ 'LAST_RECOVERY_CREDENTIAL'`) when the account has no password, no linked social account and no
781
+ verified email.** A phone-only account is the case that reaches it. The refusal is not
782
+ paternalism; it is the absence of an undo. Branch on that code to offer "set a password
783
+ first", "link an account first", or "confirm your email address first".
784
+
785
+ The same fact should shape your sign-up: an account created without a password, without an
786
+ email and given one passkey has exactly one way in, and losing the device loses the account.
787
+ Ask for a password, an address, or a social link before, or shortly after, the passkey.
788
+
789
+ #### How the ceremonies are kept honest
790
+
791
+ - **Discoverable credentials only** (`residentKey: 'required'`). `login/options` takes an empty
792
+ body — `additionalProperties: false`, so an `email` field is a 400 rather than something
793
+ quietly ignored — and always answers with an empty `allowCredentials`. There is no input
794
+ that could make its answer differ by whether an account exists.
795
+ - **A revoked credential and one that was never here answer identically** on `login/verify`.
796
+ Anything else would say whether this account once had it.
797
+ - **Challenges are one-time database rows**, spent by a single conditional `UPDATE`. Two
798
+ verifies arriving with the same challenge produce one winner and one refusal, across
799
+ instances. A challenge is bound to its ceremony (`registration` / `authentication`) and, for
800
+ enrollment, to the account that minted it.
801
+ - **A refusal leaves the challenge live.** Spending happens inside the transaction that writes
802
+ what it authorizes, so a failure rolls it back and the ceremony is retryable; only a success
803
+ is unrepeatable.
804
+ - **A signature counter that goes backwards refuses the sign-in and leaves the row alone.** It
805
+ is the signal a cloned authenticator would produce — but a synced passkey reports 0 forever
806
+ and a restored device can hit it, so auto-revoking would lock people out on a false
807
+ positive. The refusal is logged at `warn` with the passkey id; a human decides what it meant.
808
+ - **Attestation is `none`.** Verifying an attestation statement would tell us which
809
+ authenticator model was used and nothing about who is holding it.
810
+
811
+ #### Errors
812
+
813
+ | error | status | `code` | when |
814
+ |-------|--------|--------|------|
815
+ | `PasskeyChallengeError` | 401 | — | the challenge is unknown, expired, already spent, of the other ceremony, or of another account |
816
+ | `PasskeyVerificationError` | 401 | — | origin, rpId, signature or counter — and, on sign-in, an unknown or revoked credential |
817
+ | `PasskeyNotFoundError` | 404 | — | a passkey the caller does not own, or one already revoked |
818
+ | `PasskeyAlreadyRegisteredError` | 409 | — | that credential is on file for some account, revoked ones included |
819
+ | `RecentAuthenticationRequiredError` | 403 | `RECENT_AUTH_REQUIRED` | the session proved itself too long ago and carried no password |
820
+ | `LastRecoveryCredentialError` | 409 | `LAST_RECOVERY_CREDENTIAL` | revoking it would leave no way back in |
821
+ | `PasskeyConfigError` | boot | — | an origin off the rpId or not https, or an unsupported user-verification value |
822
+
823
+ #### Events
824
+
825
+ `passkeyEnrolledEvent` (`auth.passkey.enrolled`: `userId`, `passkeyId`, `label?`) and
826
+ `passkeyRevokedEvent` (`auth.passkey.revoked`: `userId`, `passkeyId`, `reason`) fire after
827
+ commit. `authLoginEvent.provider` gains `'passkey'`. Subscribe to the first to tell the owner
828
+ a new way into their account appeared — which is what it is.
829
+
830
+ #### The case table
831
+
832
+ The behaviour above is asserted row by row in
833
+ `src/__tests__/integration/passkeys.test.ts`; each `it` is named for its row.
834
+
835
+ | row | situation | outcome |
836
+ |-----|-----------|---------|
837
+ | E1 | fresh session, no passkeys | 200, empty `excludeCredentials` |
838
+ | E2 | session key 11 min old, no password | 403 `RECENT_AUTH_REQUIRED` |
839
+ | E3 / E4 | 11 min old, correct / wrong password | 200 / 403 — byte-identical to E2 |
840
+ | E5 | no password on the account, 11 min old | 403; a password can never speak for it |
841
+ | E6 | valid attestation | 200; row written, challenge spent, event emitted |
842
+ | E7 / E8 | challenge replayed / expired | 401; one row, no row |
843
+ | E9 / E10 | another account's / the other ceremony's challenge | 401 |
844
+ | E11 | credential already on some account | 409 |
845
+ | E12 / E13 | wrong origin / wrong rpId | 401 |
846
+ | E14 | two live passkeys | both listed in `excludeCredentials` |
847
+ | E15 | label empty or over 64 chars | 400; challenge stays live |
848
+ | E16 | two concurrent verifies, one challenge | one 200, one 401, one row |
849
+ | L1 / L2 | empty options body / an `email` in it | 200 with empty `allowCredentials` / 400 |
850
+ | L3 | valid assertion | 200, same answer as `login`; counter and device key move |
851
+ | L4 / L5 | revoked / unknown credential | 401, byte-identical |
852
+ | L6 / L7 / L8 | challenge spent / expired / wrong kind | 401; no device key |
853
+ | L9 / L17 | bad signature / wrong origin | 401; counter unmoved |
854
+ | L10 | counter went backwards | 401; row untouched, warn logged, not revoked |
855
+ | L11 | synced passkey reporting 0 both times | 200 |
856
+ | L12 / L13 | disabled / pending deletion | 403, the same errors password login gives |
857
+ | L14 | device-key fields missing (proxy bypassed) | 400; challenge stays live |
858
+ | L15 | an old session key named in the body | it is revoked as the new one is registered |
859
+ | L16 | two concurrent verifies, one assertion | one 200, one 401, one device key |
860
+ | M1 | 2 live + 1 revoked | 2 entries, no credential id, no public key |
861
+ | M2 / M9 | someone else's / an already revoked passkey | 404 |
862
+ | M3 / M4 | rename / revoke on a recent session | 200; revoke emits its event |
863
+ | M5 | revoke on an 11-minute-old session | 403 `RECENT_AUTH_REQUIRED` |
864
+ | M6 / M7 / M8 | last passkey, no password: alone / with a social account / with a second passkey | 409 / 200 / 200 |
865
+ | M10 | re-enrolling a revoked credential | 409 |
866
+ | K1 / K4 | two concurrent revokes: 2 passkeys and nothing else / 1 passkey and a password | 200 + 409 / 200 + 404 |
867
+ | K2 / K3 | last passkey, no password: with a verified email / phone-only | 200 / 409 |
868
+
869
+ Configuration rows C1–C6 are in `src/__tests__/unit/passkey-config.test.ts`.
870
+
281
871
  ### Writing protected routes (route DSL)
282
872
 
283
873
  This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
@@ -360,6 +950,16 @@ Both convenience URL APIs seal `metadata` into the encrypted OAuth state. On a n
360
950
  signup, the callback passes it to `beforeRegister` and `authRegisterEvent`; existing-account
361
951
  logins do not run the registration hook.
362
952
 
953
+ `returnUrl` must be a path inside your app — absolute URLs, `//host`, `..`, a backslash, and a
954
+ tab/CR/LF (which a URL parser strips, turning `/<tab>/host` into `//host`) are refused, so a
955
+ real login cannot become an open redirect. The start seams answer an unsafe value with a 400
956
+ `ValidationError`; the seams that already hold a logged-in user replace the destination instead
957
+ of failing the login — `OAuthCallback` navigates to `/` and `createOAuthCallbackHandler`
958
+ redirects to its `defaultRedirectUrl` (`/` unless you pass one). The rule is exported as
959
+ `isSafeReturnPath` from `@spfn/auth/server`, `@spfn/auth/nextjs/server`, and
960
+ `@spfn/auth/nextjs/client` for apps that validate a destination before calling
961
+ `getGoogleOAuthUrl`.
962
+
363
963
  Built-in OAuth routes: `POST /_auth/oauth/google/url`, `GET /_auth/oauth/google` (redirect),
364
964
  `GET /_auth/oauth/google/callback`, `POST /_auth/oauth/finalize`, `GET /_auth/oauth/providers`,
365
965
  plus the provider-generic `POST /_auth/oauth/start`. `getGoogleAccessToken(userId)` returns a
@@ -446,9 +1046,25 @@ process), which differs from the API process in a split deployment — the callb
446
1046
  matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
447
1047
  coordination is needed.
448
1048
 
1049
+ An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` is checked when the server boots, because the
1050
+ value used to be read lazily on the first OAuth request and a wrong one surfaced much later as
1051
+ a CSRF refusal nobody traced back to it. A value that does not parse, or whose origin is not the
1052
+ web app origin, or whose path is not `/_auth/oauth/<provider>/callback`, refuses to start — one
1053
+ error naming every offending variable:
1054
+
1055
+ ```
1056
+ SPFN_AUTH_GOOGLE_REDIRECT_URI must be on the web app origin (http://localhost:3790) at
1057
+ /_auth/oauth/google/callback: the callback's CSRF cookie is host-only and /_auth/* is forwarded
1058
+ to the API by the app's rewrite. Unset it to use the default, fix the origin, or set
1059
+ SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off for a deployment that deliberately terminates the
1060
+ callback elsewhere.
1061
+ ```
1062
+
449
1063
  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.
1064
+ cookie on the **API host**. If you use that flow in a split deployment, set the corresponding
1065
+ provider redirect URI explicitly to the API host callback **and**
1066
+ `SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off` — that is the one deployment the check is wrong
1067
+ about, and `off` is the only value that disables it.
452
1068
 
453
1069
  ### Native social sign-in (mobile / web id_token)
454
1070
 
@@ -696,6 +1312,155 @@ export default async function AdminPage()
696
1312
  Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
697
1313
  `hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
698
1314
 
1315
+ ### Emptying the cookie jar from a route handler or middleware
1316
+
1317
+ `clearSession()` works where `next/headers` is writable. The page that answers *the API
1318
+ refused your session* is usually a route handler or middleware holding a `NextResponse`
1319
+ instead — `clearSessionCookies(response)` expires the session, key-id, OAuth-pending and
1320
+ CSRF cookies on it and returns the same response, so the call chains:
1321
+
1322
+ ```typescript
1323
+ import { clearSessionCookies } from '@spfn/auth/nextjs/server';
1324
+
1325
+ export function GET(request: NextRequest)
1326
+ {
1327
+ return clearSessionCookies(NextResponse.redirect(new URL('/login', request.url)));
1328
+ }
1329
+ ```
1330
+
1331
+ Never spell the names in your app. They carry an `SPFN_PORT` suffix (`spfn_session_4001`),
1332
+ so two dev instances do not overwrite each other's cookies, and a hand-written copy of that
1333
+ rule clears the wrong cookie without failing. Read them from `sessionCookieNames()`, which
1334
+ returns `{ session, keyId, oauthPending, csrf }` at call time.
1335
+
1336
+ ## CSRF protection
1337
+
1338
+ Cookie-authenticated mutations carry a CSRF token by default. Nothing to write: the
1339
+ Next.js proxy issues the token with the session and the api client sends it back.
1340
+
1341
+ **What it protects.** The session cookie is `SameSite=Lax`, which already blocks the
1342
+ classic cross-site form POST. What remains is what Lax does not cover: a sibling
1343
+ subdomain that can write cookies on your parent domain (an XSS on `blog.example.com`
1344
+ against `app.example.com`), browsers that predate or mis-implement Lax, and a domain
1345
+ layout that drifts into `SameSite=None` later. This closes those.
1346
+
1347
+ **What it does not protect.** Nothing here helps against XSS on your own origin.
1348
+ Script running on your origin can read the token cookie and call your API as the
1349
+ user — that is true of every CSRF scheme, and no token design changes it. Same-origin
1350
+ XSS is out of scope; Content-Security-Policy and output escaping are the answer to it.
1351
+
1352
+ ### How it works
1353
+
1354
+ - On login, OAuth finalize, key rotation and every session renewal, the proxy sets
1355
+ `spfn_csrf` — a readable (non-HttpOnly) cookie holding only an HMAC of the session's
1356
+ key id, keyed by a subkey derived from `SPFN_AUTH_SESSION_SECRET`. No new variable,
1357
+ and the raw session secret is never used as the token key. Sessions that predate
1358
+ the feature get one on their first authenticated response, so upgrading does not
1359
+ require anyone to sign in again.
1360
+ - The api client mirrors the cookie into the `x-spfn-csrf` header on **every** RPC call,
1361
+ GET-shaped ones included — see "Which requests are checked" for why it cannot narrow
1362
+ that itself. Where the header is *checked* is the proxy's decision, not the client's.
1363
+ - The proxy **recomputes** the expected value from the session it just unsealed and
1364
+ compares it to the header, in constant time. It never compares the cookie to the
1365
+ header — that is the classic double-submit weakness, and it is exactly what a
1366
+ sibling subdomain defeats by tossing a cookie it chose. A tossed cookie fails here.
1367
+ - The token derives from the session key id, so rotating the key invalidates it. The
1368
+ proxy reissues the cookie in the same response that rotates or renews the session.
1369
+
1370
+ The check runs in the proxy, not the backend, because only the proxy knows the
1371
+ request's credential was ambient: it turns the session cookie into a short-lived
1372
+ bearer JWT, so the backend sees `scheme:'bearer'` for cookie callers and for genuine
1373
+ bearer clients alike.
1374
+
1375
+ ### Which requests are checked
1376
+
1377
+ Only requests the proxy authenticates from the session cookie, and only when the
1378
+ resolved **route** method is not GET/HEAD/OPTIONS.
1379
+
1380
+ Route method, not the method the browser used to reach the proxy. The api client picks
1381
+ its wire method from whether the input has a body, and holds no route map — that is the
1382
+ point of "no metadata codegen required" — so a mutation with nothing to send travels as
1383
+ GET. `logout` is `POST /_auth/logout`; `revokeOpsToken` is
1384
+ `DELETE /_auth/ops-tokens/:id`, called with only a path param. Both are `GET` on the
1385
+ wire and both are forwarded as the route's real method. A client that withheld the
1386
+ header on GET-shaped calls would therefore 403 them under `enforce`, which is why the
1387
+ contract is "every call carries it" and the proxy alone decides where it is checked.
1388
+ Gating in the proxy on the wire method would be worse still: a cross-site top-level GET
1389
+ navigation *does* carry a `SameSite=Lax` cookie, so every mutation would stay reachable
1390
+ that way.
1391
+
1392
+ Untouched, by construction: requests with no session, direct-to-backend bearer
1393
+ clients, `clientProofV1` mobile callers, machine and ops tokens. None of them pass
1394
+ through this code. A request without a session is answered exactly as before (the
1395
+ backend returns 401) — a CSRF refusal only ever answers an authenticated request, so
1396
+ the refusal itself cannot tell an anonymous caller whether anyone is signed in.
1397
+
1398
+ ### Modes
1399
+
1400
+ | Mode | Behaviour |
1401
+ |---|---|
1402
+ | `off` | No check. |
1403
+ | `warn` | **Default.** Allows the request, logs one line per request that would be refused. |
1404
+ | `enforce` | Refuses with `403 {"error":"Forbidden","message":"CSRF token missing or invalid"}`. |
1405
+
1406
+ Existing apps get signal before breakage: unset means `warn`. Watch for
1407
+ `@spfn/auth:interceptor:csrf` lines, then switch on. Apps scaffolded by `spfn init`
1408
+ start at `enforce`.
1409
+
1410
+ ```bash
1411
+ # .env.local — read by the Next.js process, where the proxy runs
1412
+ SPFN_AUTH_CSRF=enforce
1413
+ ```
1414
+
1415
+ ```typescript
1416
+ import { configureAuth } from '@spfn/auth/server';
1417
+
1418
+ configureAuth({
1419
+ csrf: {
1420
+ mode: 'enforce',
1421
+ // Exact backend route paths, params already substituted — not /api/rpc/… URLs.
1422
+ // For endpoints a browser session never calls, e.g. webhook receivers that
1423
+ // authenticate themselves by signature. An exempt path is unprotected for
1424
+ // cookie callers too, so list only endpoints that carry their own auth.
1425
+ exemptPaths: ['/webhooks/stripe'],
1426
+ },
1427
+ });
1428
+ ```
1429
+
1430
+ `configureAuth` wins over the environment variable. `enforce` and `warn` both need
1431
+ `SPFN_AUTH_SESSION_SECRET` — sessions need it anyway — and refuse rather than quietly
1432
+ passing everything if it is missing.
1433
+
1434
+ ### If a request is refused
1435
+
1436
+ A refusal in a running app almost always means the token cookie is gone or stale while
1437
+ the session is not — cleared by hand or by an extension, or a session that predates this
1438
+ feature. Rotation is not a cause: the response that rotates the key reissues the cookie
1439
+ in the same breath, and one browser has one jar, so other tabs pick the new value up
1440
+ with it.
1441
+
1442
+ Two things repair it, and both are mechanical:
1443
+
1444
+ - **The 403 carries the fix.** The proxy is the one emitting the refusal, so it sets a
1445
+ fresh `spfn_csrf` on that very response. A browser that repeats the mutation succeeds.
1446
+ The refusal is otherwise unchanged — same status, same body.
1447
+ - **Any authenticated response reissues a wrong one.** A response whose request arrived
1448
+ with no CSRF cookie, or with one that no longer matches the session, queues the
1449
+ correct value. A cookie that is merely *present* is not taken as proof it is right.
1450
+
1451
+ **The client does not retry a refused call**, so a user sees one failure before the
1452
+ repaired state takes effect — the framework fixes the browser, not the click.
1453
+
1454
+ **Limitation — calls made from the server.** A Server Component cannot set cookies at
1455
+ all, and Next.js does not forward `Set-Cookie` from a fetch the api client made on the
1456
+ server to the browser. So neither repair reaches the jar when the refused call came from
1457
+ a Server Component, a Server Action or a Route Handler; the next browser-originated
1458
+ request through the proxy is what heals it. Server-side callers otherwise need no
1459
+ change: the api client reads the whole jar through `next/headers`, and an explicit
1460
+ `cookies` option merges over that rather than replacing it. Only a caller that
1461
+ hand-builds a jar somewhere `cookies()` cannot be reached — build time, static
1462
+ generation — has to include the CSRF cookie itself.
1463
+
699
1464
  ## How do I define roles and permissions?
700
1465
 
701
1466
  Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
@@ -788,10 +1553,26 @@ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
788
1553
  });
789
1554
  ```
790
1555
 
791
- Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
1556
+ `authLoginEvent`'s `provider` is `'email'`, `'phone'`, a social provider, `'device'` or
1557
+ `'passkey'`. `'device'` is a [device-code login](#device-code-login), where the account was
1558
+ proven on another device that was already signed in and no credential was presented here;
1559
+ `'passkey'` is a [WebAuthn assertion](#passkeys-webauthn). `authRegisterEvent` accepts
1560
+ neither: a device-code request can only ever be approved by an account that already exists,
1561
+ and a passkey has to be enrolled from a session that already exists, so neither is a signup.
1562
+
1563
+ `passkeyEnrolledEvent` (`auth.passkey.enrolled`) and `passkeyRevokedEvent`
1564
+ (`auth.passkey.revoked`) fire after commit when a passkey is added or retired.
1565
+
1566
+ `authPasswordResetEvent` (`auth.password.reset`: `userId`, `email`) fires after commit when a
1567
+ [password reset](#password-reset-verified-email) completes. Distinct from a password
1568
+ *change*, which is made from a session that already proved itself: this one is made by
1569
+ whoever opened a link in a mailbox, so it is the notice to send the owner.
1570
+
1571
+ Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayload`, `InvitationCreatedPayload`,
792
1572
  `InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
793
1573
  `AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
794
- disconnect, see the OAuth unlink-notify section). These events also bind to `@spfn/core/job`
1574
+ disconnect, see the OAuth unlink-notify section), `PasskeyEnrolledPayload`,
1575
+ `PasskeyRevokedPayload`. These events also bind to `@spfn/core/job`
795
1576
  jobs via `.on(event)`.
796
1577
 
797
1578
  ## Registration gate (`beforeRegister`)
@@ -826,6 +1607,8 @@ Notes:
826
1607
  seeding in `initializeAuth()`.
827
1608
  - OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
828
1609
  per channel (reject, or allow and collect during onboarding).
1610
+ - `email` arrives trimmed and lower-cased, the same form the account is stored under, so a
1611
+ denylist or domain allowlist keyed on the address is not walked past by capitalizing it.
829
1612
  - On the `oauth` channel `email` is the provider-reported address and may be **unverified**
830
1613
  (the created account then stores `email` as `null`). The context carries
831
1614
  `emailVerified` — an email-based allow/block policy must check it before trusting `email`.
@@ -860,6 +1643,13 @@ export const opsRouter = createOpsRouter({
860
1643
  }, { auth: opsTokenAuth });
861
1644
  ```
862
1645
 
1646
+ An application that admits both credentials on one route has to tell an ops token from a
1647
+ session JWT *before* either is verified. Do not re-type the literal: `isOpsToken(bearer)`
1648
+ and the `OPS_TOKEN_PREFIX` it tests against are both exported from `@spfn/auth/server`, so
1649
+ the shape has one definition and a copy in application code cannot drift from it.
1650
+ `isOpsToken` answers shape only — it takes a raw header value, returns `false` for a
1651
+ missing or non-string one, and leaves unknown/revoked/expired to verification.
1652
+
863
1653
  `opsRoute` comes from `@spfn/core` **0.3.0-beta.2** onwards; before that release an ops
864
1654
  route spelled its own `/_ops/` prefix with `route`.
865
1655
 
@@ -903,6 +1693,81 @@ same 401, so whether a presented secret ever existed is not inferable. A valid t
903
1693
  missing a route's scope answers 403 naming only the missing scope. `'*'` grants every
904
1694
  scope.
905
1695
 
1696
+ ### One route, two credentials (`opsOrUser`)
1697
+
1698
+ An operator action is scripted today — the CLI, holding an ops token — and driven from an
1699
+ admin console tomorrow, a browser holding a user session. That is one route with two
1700
+ admissible credentials, and neither middleware admits both: `authenticate` refuses an
1701
+ `spfn_ops_` bearer before any scope guard runs (see [Machine principals](#machine-principals-registermachineverifier)),
1702
+ and `opsTokenAuth` admits nothing else.
1703
+
1704
+ ```typescript
1705
+ import { opsOrUser, getAuth, getOpsToken } from '@spfn/auth/server';
1706
+
1707
+ export const exportSignups = route.get('/admin/signups/export')
1708
+ .use([opsOrUser({ opsScopes: ['waitlist:read'], permissions: ['admin.waitlist'] })])
1709
+ // or by role: opsOrUser({ opsScopes: ['waitlist:read'], roles: ['admin'] })
1710
+ // or both (AND): opsOrUser({ opsScopes: ['waitlist:read'], roles: ['admin'], permissions: ['admin.waitlist'] })
1711
+ .handler(async (c) =>
1712
+ {
1713
+ // exactly one of these is set
1714
+ const ops = getOpsToken(c.raw); // the ops branch
1715
+ const user = getAuth(c.raw); // the session branch
1716
+ });
1717
+ ```
1718
+
1719
+ **The branch is chosen by credential shape, never by caller choice.** The raw
1720
+ `Authorization` bearer is tested with [`isOpsToken`](#ops-tokens-spfn-ops); a match runs
1721
+ `opsTokenAuth` then `requireOpsScope(...opsScopes)`, and everything else — a user JWT,
1722
+ another machine namespace, a malformed header, no header — runs `authenticate` then the
1723
+ session guards. Nothing in the request selects a branch except the credential it presents,
1724
+ so a caller cannot ask for the weaker check.
1725
+
1726
+ **`roles` and `permissions` are AND, roles first.** Two lists only ever narrow. An OR would
1727
+ mean that adding one role voids the whole permission list, which is the opposite of what a
1728
+ reader of the two lists expects. Roles run first because the role is already on the auth
1729
+ context while permissions cost a lookup — so a caller with the wrong role is refused for the
1730
+ wrong role. Giving neither list is a definition-time error, as is an empty `opsScopes`: a
1731
+ configuration that would admit a credential unchecked fails at boot, not on a request.
1732
+
1733
+ **No implicit admin bypass.** Permissions match by name only, and the ops branch has no role
1734
+ concept, so neither branch has a principal that passes by virtue of being an administrator.
1735
+ A refusal is the selected branch's own refusal, with that branch's existing status and
1736
+ message — no error class and no wire message is introduced here.
1737
+
1738
+ `opsOrUser` carries `skips: ['auth']`, so a route using it auto-skips the server-level
1739
+ `auth` middleware exactly as `optionalAuth` and `opsTokenAuth` do. No `.skip(['auth'])` by
1740
+ hand.
1741
+
1742
+ **Cookies.** The backend never reads them. A browser session reaches a route as a Bearer
1743
+ token because `@spfn/auth/nextjs/api` forwards it as one, so through the app a console
1744
+ request is the session rows below; a request carrying only a `Cookie` header is an
1745
+ unauthenticated request here.
1746
+
1747
+ | bearer | branch | answer |
1748
+ |---|---|---|
1749
+ | `spfn_ops_…` valid, scope present (or `*`) | ops | 200; `getOpsToken` set, `getAuth` null |
1750
+ | `spfn_ops_…` valid, scope missing | ops | 403 `Ops token lacks scope` |
1751
+ | `spfn_ops_…` unknown / revoked / expired | ops | 401 `Invalid ops token` (one message for all three) |
1752
+ | `spfn_ops_` prefix alone | ops | 401 `Invalid ops token` |
1753
+ | user JWT valid, permission held | user | 200; `getAuth` set, `getOpsToken` null |
1754
+ | user JWT valid, permission missing | user | 403 `InsufficientPermissionsError` |
1755
+ | user JWT expired / bad signature | user | 401 (the existing `authenticate` message) |
1756
+ | token in a *registered* machine namespace, not ops | user | 401 — the user path admits no machine credential |
1757
+ | malformed bearer / no `Authorization` | user | 401 |
1758
+ | session cookie only, no bearer | user | 401 — see Cookies above |
1759
+ | `x-spfn-auth-profile` + user JWT | user | `PROFILE_REJECTED` (existing `authenticate` behaviour) |
1760
+ | `x-spfn-auth-profile` + ops token | ops | header ignored; `opsTokenAuth` reads `Authorization` only |
1761
+ | ops token on a plain `authenticate` route | — | 401, unchanged |
1762
+ | `opsScopes: []`, or neither `roles` nor `permissions` | — | throws at definition |
1763
+ | server-level `auth` registered | — | auto-skipped on this route |
1764
+ | `roles: ['admin']` only; role admin | user | 200 |
1765
+ | `roles: ['admin']` only; role user | user | 403 `InsufficientRoleError` |
1766
+ | `roles` + `permissions`; role matches, permission missing | user | 403 `InsufficientPermissionsError` |
1767
+ | `roles` + `permissions`; permission held, role wrong | user | 403 `InsufficientRoleError` (role is checked first) |
1768
+
1769
+ `opsOrUser` is available from **0.3.0-beta.11**.
1770
+
906
1771
  ## Mobile clientProofV1 (`@spfn/auth/client-proof`)
907
1772
 
908
1773
  Server side of the spfn-mobile native SDK auth profile (issue #46; asymmetric revision in
@@ -917,6 +1782,12 @@ the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` ·
917
1782
  `SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
918
1783
  HTTP status).
919
1784
 
1785
+ Before minting the first proof in each client process, the client calls the built-in
1786
+ `GET /_core/time` operation (`core.time`) and establishes its proof epoch from
1787
+ `serverTimeMillis`. This prerequisite is unproven and session-free. If the operation is
1788
+ unavailable or its response cannot be decoded, proof minting fails closed — there is no silent
1789
+ fallback to the device's unsynchronized wall clock.
1790
+
920
1791
  - Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
921
1792
  `x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
922
1793
  - A request body must be **byte-canonical** — a body that parses but re-encodes differently is
@@ -944,6 +1815,29 @@ HTTP status).
944
1815
  construction or through the `/control/register-key` hook; the private half never reaches
945
1816
  the server. No persistence — a production enrollment/rotation story is phase 2.
946
1817
 
1818
+ ### Clock synchronization and proof-time boundaries (contract 0.9.0)
1819
+
1820
+ `core.time` is imported from `@spfn/core` rather than restated by auth: operation ID, method,
1821
+ path, auth class, session requirement, and the closed `ServerTimeResponse` schema all come from
1822
+ the core route contract. The mobile contract records it as a bodyless GET prerequisite and
1823
+ requires one synchronization before the first proof minted in each process. It does not prescribe
1824
+ persistent offset storage, retry sleeps, or device-specific margins.
1825
+
1826
+ The server admission rule remains strict: `age = serverNow - issuedAtMillis` must satisfy
1827
+ `0 <= age <= 300000`. Synchronization does not widen the replay window or change nonce retention.
1828
+ A refused request still leaves its nonce unused; only admission spends it.
1829
+
1830
+ | `serverNow - issuedAtMillis` | Result |
1831
+ |---:|---|
1832
+ | `0` | accept |
1833
+ | `-1` (proof is 1 ms in the future) | `PROOF_EXPIRED` |
1834
+ | `300000` | accept |
1835
+ | `300001` | `PROOF_EXPIRED` |
1836
+
1837
+ When `core.time` cannot be read, the client must surface that synchronization failure and stop
1838
+ before sending a proof. Using `Date.now()` or a platform wall clock as an implicit fallback would
1839
+ reintroduce the skew failure this prerequisite closes.
1840
+
947
1841
  ### The contract version on the wire (contract 0.6.0)
948
1842
 
949
1843
  A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
@@ -992,6 +1886,8 @@ Every operation in the exported bundle carries `since` — the contract version
992
1886
  | `auth.clientProof.handshake`, `echo.send`, `items.list` | 0.1.0 |
993
1887
  | `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
994
1888
  | `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
1889
+ | `core.time` | 0.9.0 |
1890
+ | `auth.device.start`, `auth.device.poll`, `auth.device.info`, `auth.device.approve`, `auth.device.deny` | 0.10.0 |
995
1891
 
996
1892
  - **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
997
1893
  contract version passes or refuses the whole surface, so these three fields change no verdict here.
@@ -1063,6 +1959,386 @@ app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
1063
1959
  Responses and errors MUST be canonical bytes with the contract envelope — build them with
1064
1960
  `encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
1065
1961
 
1962
+ ## Custom auth profiles (`registerAuthProfile`)
1963
+
1964
+ `clientProofV1` is not a special case in the middleware — it is one entry in a registry
1965
+ `authenticate` and `optionalAuth` dispatch on. An app registers its own scheme the same way,
1966
+ without forking the middleware or wrapping it:
1967
+
1968
+ ```typescript
1969
+ import { registerAuthProfile, type AuthContext } from '@spfn/auth/server';
1970
+ import { UnauthorizedError } from '@spfn/core/errors';
1971
+
1972
+ // At boot — server.config.ts, before the server starts taking requests.
1973
+ registerAuthProfile('serviceTokenV1', {
1974
+ verify: async (c): Promise<AuthContext> =>
1975
+ {
1976
+ const user = await findServiceAccount(c.req.header('x-acme-service-token'));
1977
+ if (user === null)
1978
+ {
1979
+ // A refusal leaves the verifier as a throw. It reaches the app's
1980
+ // error handler exactly as the Bearer path's does.
1981
+ throw new UnauthorizedError({ message: 'Invalid service token' });
1982
+ }
1983
+
1984
+ return {
1985
+ user,
1986
+ userId: String(user.id),
1987
+ keyId: 'service-token',
1988
+ role: null,
1989
+ locale: 'en',
1990
+ scheme: 'serviceTokenV1',
1991
+ };
1992
+ },
1993
+ });
1994
+ ```
1995
+
1996
+ A request naming the profile is then answered by that verifier:
1997
+
1998
+ ```http
1999
+ POST /v1/reports
2000
+ x-spfn-auth-profile: serviceTokenV1
2001
+ x-acme-service-token: <the app's own credential>
2002
+ ```
2003
+
2004
+ - **Register at boot, before the first request.** The registry is read on every dispatch, so a
2005
+ profile registered later is simply a profile the requests before it did not have. Registration
2006
+ is not frozen after startup — it is a contract, not a runtime check.
2007
+ - **A duplicate name throws**, `clientProofV1` included. Replacing a registered verifier silently
2008
+ is how an import order or a copied profile name swaps the code that decides who is admitted, so
2009
+ there is no override — and no unregistration API for the same reason.
2010
+ - **The verifier must expose a callable `verify`**, and what it resolves must carry a `userId` — a
2011
+ verifier that cannot admit anyone is refused at boot, and a resolve without a principal (`null`,
2012
+ the JS idiom for "no user") is refused as a throw rather than routed as authenticated.
2013
+ - **An unknown profile is still refused** (`PROFILE_REJECTED`, 400): registering one name does not
2014
+ open the header to others.
2015
+ - **Mixing is still refused.** A request carrying both `x-spfn-auth-profile` and `Authorization` is
2016
+ rejected before either path runs; a custom verifier never sees it.
2017
+ - **A verifier's throw propagates**, and only the internal clientProofV1 contract refusal is
2018
+ answered with the canonical envelope. Under `optionalAuth` too: credentials that were presented
2019
+ and refused are never downgraded to anonymous passage — only "presented nothing" continues
2020
+ without an auth context.
2021
+ - **`AuthContext.scheme` is an open union** — `'bearer' | 'clientProofV1' | 'oneTimeToken' | (string
2022
+ & {})`. The built-in names keep their autocomplete and a registered profile names its own scheme.
2023
+ The field stays informational: downstream permission and tenant code takes one principal shape and
2024
+ never branches on how it was produced.
2025
+
2026
+ ## Authorization server for MCP clients
2027
+
2028
+ Let Claude Code and Codex connect to your app's `/mcp` endpoint as the user, over the flow
2029
+ they already speak: OAuth 2.1 with dynamic client registration and PKCE.
2030
+
2031
+ ```console
2032
+ $ claude mcp add --transport http acme https://api.acme.com/mcp
2033
+ $ claude
2034
+ > /mcp
2035
+ ```
2036
+
2037
+ Between those two lines the CLI discovers `/.well-known/oauth-authorization-server`, registers
2038
+ itself, opens a browser at your consent screen, catches the redirect on a loopback port, and
2039
+ exchanges the code for a token. Nobody pastes anything.
2040
+
2041
+ The feature is opt-in and the opt-in is one block:
2042
+
2043
+ ```typescript
2044
+ createAuthLifecycle({
2045
+ authorizationServer: {
2046
+ scopes: {
2047
+ 'mcp:read': 'Read your projects and tasks',
2048
+ 'mcp:write': 'Create and edit your tasks',
2049
+ },
2050
+ defaultScopes: ['mcp:read'], // what a request with no `scope` asks for. default: all of them
2051
+ // issuer: 'https://api.acme.com', // default: SPFN_API_URL
2052
+ // authorizeUrl: 'https://acme.com/oauth/authorize', // default: {app url}/oauth/authorize
2053
+ // allowedRedirectOrigins: [], // https origins a client may register. loopback needs no entry
2054
+ // accessTokenTtlMs: 8 * 60 * 60 * 1000, // default 8 hours
2055
+ // refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, // default 30 days
2056
+ // codeTtlMs: 60 * 1000, // default 60 seconds
2057
+ },
2058
+ })
2059
+ ```
2060
+
2061
+ Without that block every endpoint below answers 404 and nothing else changes — including the
2062
+ boot check, which does not run. `scopes` is the one setting with no default: the names are
2063
+ your application's vocabulary, they are published in the metadata document and read aloud on
2064
+ the consent screen, and there is nothing to derive them from.
2065
+
2066
+ | Endpoint | Host | Auth | What it is |
2067
+ | --- | --- | --- | --- |
2068
+ | `GET /.well-known/oauth-authorization-server` | API | public | RFC 8414 discovery — the first request any client makes |
2069
+ | `POST /_auth/oauth2/register` | API | public, IP rate limited | RFC 7591 dynamic registration. Public clients only |
2070
+ | `GET /_auth/oauth2/authorize` | API | `authenticate` | What the consent screen should say. Records nothing |
2071
+ | `POST /_auth/oauth2/authorize` | API | `authenticate` | The decision. Mints the code |
2072
+ | `POST /_auth/oauth2/token` | API | public, IP rate limited | `authorization_code` and `refresh_token` |
2073
+ | `POST /_auth/oauth2/revoke` | API | public (RFC 7009) | `client_id` required; 200 for an unknown token as surely as for a real one |
2074
+ | `GET /_auth/oauth2/grants` · `DELETE /_auth/oauth2/grants/:id` | API | `authenticate` | What the user has connected, and the button that disconnects it |
2075
+ | `GET /oauth/authorize` · `POST /oauth/authorize` | web | session | The consent screen itself — see the note at the end |
2076
+
2077
+ Two lines wire it to `@spfn/mcp`:
2078
+
2079
+ ```typescript
2080
+ import { verifyAccessToken } from '@spfn/auth/server';
2081
+
2082
+ export const mcp = createMcpRoute({ validateToken: verifyAccessToken, tools: [...] });
2083
+ ```
2084
+
2085
+ `verifyAccessToken(token, resource)` answers `{ clientId, scopes, expiresAt, userId }` or
2086
+ `null`, and `null` is a refusal — `@spfn/mcp` ≥ 0.3.0-beta.3 accepts it as one rather than
2087
+ requiring a throw. `expiresAt` is seconds since the epoch, like every other OAuth field here.
2088
+
2089
+ - **Only loopback and origins you allowed.** A client may register `http://localhost:*`,
2090
+ `http://127.0.0.1:*` or `http://[::1]:*` — a CLI cannot know which port the OS will hand it,
2091
+ so the **port** is the one thing allowed to vary. Nothing else does: host, path and query
2092
+ must match the registration exactly, a fragment is refused at registration and at request,
2093
+ and plain `http` anywhere else is refused outright. An `https` redirect URI has to be on an
2094
+ origin listed in `allowedRedirectOrigins`.
2095
+ - **Those three spellings are three registrations.** `localhost`, `127.0.0.1` and `[::1]` do
2096
+ not stand in for one another — they resolve differently on a machine with a split-horizon
2097
+ resolver, and a client answered on a host it did not register is a client something
2098
+ redirected. IPv6 is the one place spelling is folded: `http://[0:0:0:0:0:0:0:1]:5/cb` and
2099
+ `http://[::1]:5/cb` are the same registration, because both sides are read through
2100
+ `new URL(...).hostname`.
2101
+ - **An unknown client or a mismatched redirect URI is shown, never redirected.** There is no
2102
+ vetted URI to send that error to, and sending it to the one the request supplied is the open
2103
+ redirect the whole rule exists to close. Every other authorize-time error —
2104
+ `invalid_request`, `invalid_scope`, `invalid_target`, `access_denied` — goes back to the
2105
+ client on its registered URI, which is the only form the waiting CLI can read.
2106
+ - **PKCE S256, and nothing else.** No `plain`, and no request without a challenge. The code
2107
+ arrives on a loopback port that any process on the machine could have been listening on.
2108
+ - **`resource` is required** (RFC 8707) and the token is only good against it. A token your
2109
+ user approved for your MCP server cannot be replayed against a neighbouring deployment that
2110
+ shares this authorization server.
2111
+ - **A code is spent by the statement that reads it**, so of two exchanges arriving together
2112
+ exactly one gets tokens — and **presenting a code twice revokes the grant**, because by then
2113
+ somebody else may hold what the first exchange produced.
2114
+ - **Refresh tokens rotate, and a rotated one is marked rather than deleted.** Presenting it
2115
+ again revokes the grant, which kills the replacement as well as the replayed token: both
2116
+ hang off the grant and there is no telling which holder is the thief. A refresh may ask for
2117
+ a **subset** of the granted scopes and never for more; narrowing applies to that request and
2118
+ leaves the user's consent record as they gave it.
2119
+ - **Every code and refresh failure is one `invalid_grant`, word for word.** Unknown, expired,
2120
+ spent, wrong verifier, another client's. The endpoint is public, and an error that told
2121
+ those apart would answer the question somebody holding a stolen value is asking.
2122
+ - **Token endpoint errors are RFC 6749 §5.2, not the SPFN envelope** —
2123
+ `{ "error": "invalid_grant", "error_description": "..." }`, status 400,
2124
+ `Cache-Control: no-store`. The client reading it is an OAuth library that knows those two
2125
+ field names and nothing about this framework. Registration refusals are RFC 7591 §3.2.2 the
2126
+ same way (`invalid_redirect_uri`, `invalid_client_metadata`).
2127
+ - **Nothing but a hash is stored.** Codes and tokens are `spfn_at_<64 hex>` /
2128
+ `spfn_rt_<64 hex>` / 43 url-safe characters, and the value exists in the clear exactly once,
2129
+ in the response that issues it. It is never logged and never put in an event.
2130
+ - **A global revocation reaches the grants.** `revoke-all`, a password change, a completed
2131
+ password reset and a deletion request each revoke every grant the account has — so a CLI
2132
+ holding a refresh token through "sign me out everywhere" cannot be back within the hour,
2133
+ which is exactly the client that call was aimed at. The user's own
2134
+ `DELETE /_auth/oauth2/grants/:id` does the same for one client, immediately.
2135
+ - **The issuer is checked at boot.** It must be an absolute URL with no path — the metadata
2136
+ document is served at an origin's root and nowhere else — and it must be `https`, or `http`
2137
+ on `localhost` / `127.0.0.1` / `[::1]` for development. Anything else refuses to start with
2138
+ a message naming `SPFN_API_URL` or `authorizationServer.issuer`, whichever the value came
2139
+ from. An application with no `authorizationServer` block never reaches this check. The one
2140
+ value that is accepted and rewritten is a bare trailing slash: `https://api.acme.com/` is
2141
+ stored as `https://api.acme.com`, the form `@spfn/mcp` derives, so the two documents naming
2142
+ this server agree (RFC 8414 §3.3). That reduction happens where the config is resolved, not
2143
+ in the boot check, so a document read without the lifecycle hook publishes the same issuer.
2144
+ - **Unapproved client rows are swept.** Registration is unauthenticated by necessity, so
2145
+ `auth.oauth2.client-purge` (in `authJobRouter`, daily at 05:00) deletes clients older than a
2146
+ day that no user ever approved. One with a grant against it is never touched. Registration
2147
+ is also capped per IP two ways — a burst rate limit, and a cap on how many unapproved
2148
+ clients one address may have standing, which a rate limit cannot express.
2149
+ - **`/mcp` tokens are not sessions.** An access token issued here authorizes the MCP surface
2150
+ for the resource it names. It is not a user session and is not accepted by ordinary API
2151
+ routes.
2152
+
2153
+ ### The consent screen
2154
+
2155
+ The screen itself is one route file on the web app, at the path published as
2156
+ `authorization_endpoint`:
2157
+
2158
+ ```typescript
2159
+ // app/oauth/authorize/route.ts
2160
+ import { createOAuth2AuthorizeHandlers } from '@spfn/auth/nextjs/server';
2161
+
2162
+ export const { GET, POST } = createOAuth2AuthorizeHandlers({ loginPath: '/login' });
2163
+ ```
2164
+
2165
+ `GET` asks `GET /_auth/oauth2/authorize` what the request is and draws it; `POST` checks the
2166
+ form's own CSRF token, sends the decision to `POST /_auth/oauth2/authorize`, and redirects the
2167
+ browser back to the waiting CLI. Neither decides anything — the API validates the request from
2168
+ scratch both times, because the form between the two calls is in the user's browser.
2169
+
2170
+ | Option | What it is |
2171
+ | --- | --- |
2172
+ | `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 |
2173
+ | `render?` | `(view: OAuth2ConsentView) => string`, replacing the default body. Status, headers and the field set stay the handler's |
2174
+
2175
+ Every answer carries `Cache-Control: no-store`, and every page also carries
2176
+ `Content-Type: text/html; charset=utf-8` and `Content-Security-Policy: frame-ancestors 'none'`
2177
+ — a consent screen that can be framed is a consent screen that can be clickjacked.
2178
+
2179
+ - **The two refusal kinds become the two answers.** `unknown_client` and
2180
+ `redirect_uri_mismatch` are shown on a 400 screen with no `Location` at all. Every other
2181
+ refusal — `invalid_request`, `invalid_target`, `invalid_scope`, `access_denied` — is a 302 to
2182
+ the redirect URI **the API returned**, carrying `error=` and the `state` verbatim. The
2183
+ `redirect_uri` in the request is forwarded to the API and never built into a `Location`: the
2184
+ API's value is the one that matched a registration, which is the whole difference between a
2185
+ redirect and an open redirect.
2186
+ - **The POST carries its own CSRF token.** The page puts the readable CSRF cookie in a hidden
2187
+ `csrf` field and the POST refuses, before calling the API at all, unless the field matches
2188
+ the cookie. The handler's server-side call to the API mints the CSRF header itself and would
2189
+ always pass, so the form's token is the only check that means anything here.
2190
+ - **`render` owns the body and nothing else.** `OAuth2ConsentView` carries `clientName`,
2191
+ `redirectHost`, `scopes`, `resource`, the `fields` to echo as hidden inputs, and the
2192
+ `csrfToken`, all raw — put every one of them through the exported `escapeHtml`. `clientName`
2193
+ arrives from unauthenticated dynamic registration, and a renderer that drops `fields` or
2194
+ `csrfToken` produces a form the API refuses.
2195
+
2196
+ The end-to-end path — lifecycle config, this route, `/mcp`, and connecting from Claude Code
2197
+ and Codex — is [docs/guides/mcp-clients.md](../../docs/guides/mcp-clients.md).
2198
+
2199
+ ## Machine principals (`registerMachineVerifier`)
2200
+
2201
+ A machine credential is issued by a service to a non-interactive process, and its subject is
2202
+ an account or a tenant, not a person. `AuthContext` cannot hold one — it requires a `users`
2203
+ row — and resolving a machine token to its owning user is worse than the type error: it makes
2204
+ the machine's request indistinguishable from that user's own session.
2205
+
2206
+ So a machine principal never enters `AuthContext`. It lives in its own context key, is read by
2207
+ its own helper, and is admitted by its own middleware:
2208
+
2209
+ ```typescript
2210
+ import { machineAuth, requireMachineScope, getMachinePrincipal } from '@spfn/auth/server';
2211
+
2212
+ export const ingest = route.post('/v1/ingest')
2213
+ .use([machineAuth, requireMachineScope('events:write')])
2214
+ .handler(async (c) =>
2215
+ {
2216
+ const { subjectType, subjectId } = getMachinePrincipal(c.raw)!;
2217
+ // subjectType: 'account' | 'service' | whatever the verifier named
2218
+ });
2219
+ ```
2220
+
2221
+ `getAuth(c)` on that route returns nothing, because nothing put a user there. That is the
2222
+ whole design: a machine request cannot impersonate a user session, not because a check
2223
+ forbids it but because no code path leads there.
2224
+
2225
+ **Ownership is not authentication.** Who issued a machine token, who owns it, and who may
2226
+ revoke or audit it are the registrant's data-level concerns — put the token id in `claims` and
2227
+ answer them from your own tables. What the request *acts as* is the token's own subject and
2228
+ scopes, and nothing here resolves a machine subject to a user.
2229
+
2230
+ ### Registering a verifier
2231
+
2232
+ A verifier claims one namespace, by a raw `tokenPrefix` (for an opaque secret, the
2233
+ `spfn_ops_` shape) or by a `kidPrefix` on the unverified JOSE header of a JWS. The built-in
2234
+ ops token's own shape is exported rather than spelled out — match it with `isOpsToken` or
2235
+ `OPS_TOKEN_PREFIX` from `@spfn/auth/server`. Register at boot, before the first request:
2236
+
2237
+ ```typescript
2238
+ import { registerMachineVerifier } from '@spfn/auth/server';
2239
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
2240
+
2241
+ const RUNTIME_JWKS = createRemoteJWKSet(new URL('https://issuer.example.com/.well-known/jwks.json'));
2242
+
2243
+ registerMachineVerifier({
2244
+ id: 'runtimeJwsV1',
2245
+ match: { kidPrefix: 'machine:runtime:' },
2246
+ verify: async (token) =>
2247
+ {
2248
+ const { payload } = await jwtVerify(token, RUNTIME_JWKS, { issuer: 'https://issuer.example.com' });
2249
+
2250
+ return {
2251
+ subjectType: 'account',
2252
+ subjectId: String(payload.sub),
2253
+ scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
2254
+ claims: { tokenId: payload.jti },
2255
+ scheme: 'runtimeJwsV1',
2256
+ };
2257
+ },
2258
+ });
2259
+ ```
2260
+
2261
+ The request carries it as an ordinary bearer token — no new wire format, and the
2262
+ profile-header channel is not involved:
2263
+
2264
+ ```http
2265
+ POST /v1/ingest
2266
+ Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hY2hpbmU6cnVudGltZTo...
2267
+ ```
2268
+
2269
+ - **Namespace your kids.** `machine:` is the convention this package documents, and a user
2270
+ session JWT never carries that shape. The prefix is what tells the two apart before either
2271
+ is verified.
2272
+ - **Conflicting discriminators are refused at registration** — a duplicate `id`, a duplicate
2273
+ prefix, or a prefix that would shadow an already-registered one (`machine:` swallowing
2274
+ `machine:runtime:`). Two verifiers one token could match would make admission depend on
2275
+ registration order, so that is a boot-time error rather than something the dispatch
2276
+ resolves per request.
2277
+ - **A `tokenPrefix` claims every token that starts with it**, and `authenticate` consults the
2278
+ registry before it decodes anything. A prefix a user's JWT could begin with (`ey…`) would
2279
+ therefore refuse every user session — pick a prefix no other credential on your surface
2280
+ shares, as `spfn_ops_` does.
2281
+ - **Register at boot, before the first request.** The registry is module state read on every
2282
+ dispatch, so a verifier registered later is simply a verifier the requests before it did
2283
+ not have. There is no unregistration and no reset — the same contract, and the same reason,
2284
+ as [`registerAuthProfile`](#custom-auth-profiles-registerauthprofile).
2285
+ - **Registering nothing costs nothing.** With no verifier registered, `authenticate` is two
2286
+ array-length checks away from what it was. The unverified JOSE header peek happens only
2287
+ once a `kidPrefix` verifier exists.
2288
+ - **`scheme` is the registry's answer**, not the verifier's: whatever a verifier returns
2289
+ there, the principal carries the `id` that admitted it, so an audit trail cannot be made to
2290
+ name the wrong verifier.
2291
+
2292
+ ### The case table
2293
+
2294
+ | credential ↓ route → | `authenticate` (user) | `machineAuth` | `optionalAuth` |
2295
+ |---|---|---|---|
2296
+ | user bearer JWT | ✓ user (unchanged) | 401 | ✓ user (unchanged) |
2297
+ | machine token, registered namespace, valid | 401 — refused before the token is decoded | ✓ sets `machinePrincipal` | 401 |
2298
+ | machine token, registered namespace, verifier rejects | 401 | 401 | 401 |
2299
+ | machine-shaped token, unregistered namespace | 401 (the existing invalid-token path) | 401 | continues, no auth |
2300
+ | profile header + any Bearer | `PROFILE_REJECTED` (unchanged) | `PROFILE_REJECTED` | `PROFILE_REJECTED` |
2301
+ | nothing | 401 (unchanged) | 401 | continues, no auth |
2302
+ | valid principal, missing scope | — | 403 | — |
2303
+ | valid principal, sufficient scope | — | 200 | — |
2304
+
2305
+ Every 401 above is one message. Whether a namespace is registered, whether a presented token
2306
+ was ever valid, and whether a verifier rejected it are not inferable from the answer — the
2307
+ same non-disclosure rule the [ops-token](#ops-tokens-spfn-ops) table keeps. 403 is reserved
2308
+ for scope, where the caller is already authenticated; `requireMachineScope` matches scopes
2309
+ exactly and has no wildcard, and it fails closed with a 401 if it runs without `machineAuth`
2310
+ before it.
2311
+
2312
+ A verifier that throws something other than a refusal — a bug in registrant code — is the
2313
+ same generic 401 on the wire, with the real error logged. Never a 500 carrying registrant
2314
+ internals, and never a silent pass.
2315
+
2316
+ The last row of the unregistered-namespace case is the one asymmetry: a token in a namespace
2317
+ nobody registered is not a machine credential as far as this package can tell, so under
2318
+ `optionalAuth` it gets what any unusable bearer token has always got. A token in a
2319
+ *registered* namespace is refused there, because refusing it is the difference between
2320
+ "presented the wrong credential" and "presented none".
2321
+
2322
+ The non-disclosure above is therefore an `authenticate` and `machineAuth` property, not an
2323
+ `optionalAuth` one: on an `optionalAuth` route a caller can tell a registered namespace from
2324
+ an unregistered one, because one is refused and the other is served anonymously. Closing that
2325
+ gap would mean refusing every unusable bearer token on those routes — a change to behaviour
2326
+ that predates machine principals, and a worse trade than the inference it prevents. Mount
2327
+ `machineAuth` where the distinction matters.
2328
+
2329
+ ### Issuance is yours
2330
+
2331
+ This package verifies machine tokens; it does not mint them. Issuance, rotation, and
2332
+ revocation belong to whoever owns the subject — keep the tokens short-lived, and prefer a
2333
+ signature you can verify offline (`kidPrefix` + JWKS) over a secret you must look up.
2334
+
2335
+ `opsTokenAuth` is the built-in instance of exactly this pattern, hand-written for one
2336
+ credential before the registry existed: its own context key (`opsToken`), its own scope guard,
2337
+ `AuthContext` never set. It keeps its own implementation and is not registered here.
2338
+ A route that must admit an ops token *or* a user session uses
2339
+ [`opsOrUser`](#one-route-two-credentials-opsoruser), which composes the two existing
2340
+ middleware pairs behind one branch on credential shape rather than widening either path.
2341
+
1066
2342
  ## Account Deletion & Recovery
1067
2343
 
1068
2344
  Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
@@ -1117,7 +2393,7 @@ export default defineServerConfig()
1117
2393
  },
1118
2394
  },
1119
2395
  }))
1120
- .jobs(authJobRouter) // registers the daily (04:00 UTC) purge sweep
2396
+ .jobs(authJobRouter) // the daily (04:00 UTC) purge sweep, and auth.link-mail
1121
2397
  .routes(appRouter)
1122
2398
  .build();
1123
2399
  ```
@@ -1154,15 +2430,68 @@ is fixed at module-import time, which happens before `createAuthLifecycle()` run
1154
2430
  `createAuthLifecycle()` call, and register that instead:
1155
2431
 
1156
2432
  ```typescript
1157
- import { createAuthDeletionJobRouter } from '@spfn/auth/server';
2433
+ import { createAuthJobRouter } from '@spfn/auth/server';
1158
2434
 
1159
2435
  // ... after .lifecycle(createAuthLifecycle({ deletion: { purgeCron: '0 3 * * *' } }))
1160
- .jobs(createAuthDeletionJobRouter({ purgeCron: '0 3 * * *' }))
2436
+ .jobs(createAuthJobRouter({ purgeCron: '0 3 * * *' }))
1161
2437
  ```
1162
2438
 
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.
2439
+ Register **only one** of `authJobRouter` / `createAuthJobRouter(...)` — both build the same job
2440
+ names, so registering both (e.g. the static export *and* a custom-cron router) double-registers
2441
+ each name against pg-boss instead of overriding it.
2442
+
2443
+ `createAuthDeletionJobRouter` is the former name of `createAuthJobRouter` and still works, with
2444
+ the same argument and the same result. It is deprecated because the router has carried more than
2445
+ the deletion purge since `auth.link-mail` joined it.
2446
+
2447
+ ### Link mail delivery
2448
+
2449
+ `auth.link-mail` is the second job on the router, and the reason to register the router even in
2450
+ an app that never deletes an account.
2451
+
2452
+ **What it queues, and why only a row id.** Three mails leave through it: the verified-email
2453
+ signup link, the password reset link, and the "you already have an account" notice the signup
2454
+ request answers a known address with. The payload is `{ kind, rowId }` — or `{ kind, target,
2455
+ targetType }` for the notice — and never the token, the URL or the rendered mail.
2456
+ `@spfn/notification` can queue a send of its own, but its payload carries the *rendered* mail,
2457
+ which for these three templates would leave the link token in plaintext in `pgboss.job` until
2458
+ archive. So the queue carries a reference and the worker mints the credential moments before
2459
+ sending it: the plaintext exists in the mail and nowhere else.
2460
+
2461
+ **What that buys.** The request writes its row with `token_hash` null and answers. Both branches
2462
+ of both endpoints now cost the same database work, so how long a request took no longer says
2463
+ whether the address has an account — the mail was the only asymmetry left. A pending row is not
2464
+ confirmable: a null hash matches no lookup, and the worker's `issue` refuses a row that was
2465
+ superseded, consumed, completed or expired in the meantime, in the same statement that would
2466
+ write the hash. A failed send throws so pg-boss retries, and the retry re-mints, which is why a
2467
+ token from a failed attempt stops working.
2468
+
2469
+ **The three modes** — `SPFN_AUTH_LINK_MAIL_DELIVERY`:
2470
+
2471
+ | mode | behaviour |
2472
+ |------|-----------|
2473
+ | `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 |
2474
+ | `queued` | always queue; an enqueue failure surfaces as a failed request rather than becoming an inline send |
2475
+ | `inline` | always send on the request — today's behaviour, and the timing signal that comes with it |
2476
+
2477
+ **When the provider refuses on the request path** — `inline`, `auto` with no pg-boss, or the
2478
+ fallback below — the failure is logged and the request still answers as if the mail had gone out,
2479
+ because an answer that depended on the mail provider would be an account-existence oracle during
2480
+ an outage; the user asks again, and only the job path retries.
2481
+
2482
+ **The fallback warning.** In `auto`, an app that initialised pg-boss but never registered this
2483
+ router has no `auth.link-mail` queue, so the enqueue fails. Losing the mail there would be silent,
2484
+ so that request sends inline instead and the log says once per process:
2485
+
2486
+ ```
2487
+ Queue auth.link-mail does not exist, so this link mail was sent on the request path.
2488
+ Register the auth job router — .jobs(authJobRouter) — or set SPFN_AUTH_LINK_MAIL_DELIVERY='inline'.
2489
+ ```
2490
+
2491
+ The fix is in the message: register the router, or say `inline` if sending on the request is what
2492
+ you want. Only a missing queue falls back — every other enqueue failure, a database outage above
2493
+ all, surfaces, because falling back on those would hide the outage behind mail that still gets
2494
+ through.
1166
2495
 
1167
2496
  ## FAQ
1168
2497
 
@@ -1176,9 +2505,20 @@ deploy.
1176
2505
  Almost always the callback origin. The CSRF check is a double-submit against a host-only
1177
2506
  cookie set on your **web app** host, so the provider must return to the web app origin, and
1178
2507
  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
2508
+ callback 404s — including in local dev. An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` on the
2509
+ wrong origin or path no longer gets that far: it fails at boot with a message naming the
2510
+ variable. Details in
1180
2511
  [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
1181
2512
 
2513
+ **I forgot my password.**
2514
+ Send the address to `requestPasswordReset` and open the link that arrives. Any `active`
2515
+ account whose email is verified — or that already has a password, which covers every account
2516
+ created before the column was stamped — can be reset that way. See
2517
+ [Password reset](#password-reset-verified-email). Completing it signs every other device out,
2518
+ so it is also the answer to "someone else knows my password". An account with neither a
2519
+ verified address nor a password (OAuth-only, provider said unverified) cannot be reset by
2520
+ email; it signs in through its provider.
2521
+
1182
2522
  **Does the server hold my users' private keys?**
1183
2523
  No. The client generates an ES256/RS256 keypair, sends only the public key on register or
1184
2524
  login, and signs each request itself. The server verifies with the stored public key. Keys
@@ -1208,6 +2548,23 @@ authorization.
1208
2548
  The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
1209
2549
  verified, active, and required to change their password on first login.
1210
2550
 
2551
+ **Is `Foo@Example.com` the same account as `foo@example.com`?**
2552
+ Yes. Addresses are trimmed and lower-cased on the way in and on the way out, so one person
2553
+ who capitalizes differently on different days reaches one account instead of creating a
2554
+ second. Nothing else is folded — Gmail's dot and `+` rules are that provider's delivery
2555
+ behaviour, not an internet rule, and applying them would merge addresses other providers
2556
+ treat as different people.
2557
+
2558
+ `createAuthLifecycle()` brings existing rows into the same form on startup. If two accounts
2559
+ differ only by capitalization, both are left exactly as they are and their user ids are
2560
+ logged as an error: which one is the real account, and what becomes of the other's data, is
2561
+ not a question the package can answer for you. Until you resolve it, the mixed-case one
2562
+ cannot sign in.
2563
+
2564
+ Admin seeding is unaffected either way. It recognizes a configured admin in whatever form
2565
+ the address was stored, so an account the backfill has not reached is skipped rather than
2566
+ duplicated into a second privileged row holding the configured password.
2567
+
1211
2568
  ## Pitfalls & anti-patterns
1212
2569
 
1213
2570
  - **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
@@ -1245,8 +2602,10 @@ verified, active, and required to change their password on first login.
1245
2602
  '@spfn/notification/server'`). Wire verification-code / invitation emails through its events.
1246
2603
  - **`authJobRouter` isn't registered for you.** `createAuthLifecycle()`'s `afterInfrastructure`
1247
2604
  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).
2605
+ opportunity to auto-register the jobs. Call `.jobs(authJobRouter)` yourself — see
2606
+ [Account Deletion & Recovery](#account-deletion--recovery). An app that initialises pg-boss and
2607
+ skips this keeps sending link mail, but on the request path, with a warning naming the router —
2608
+ see [Link mail delivery](#link-mail-delivery).
1250
2609
  - **`USER_STATUSES` gained `pending_deletion` / `deleted`.** Any code with a `switch(user.status)`
1251
2610
  or an exhaustive status union must handle both — `enumText` is plain `text` with no DB `CHECK`,
1252
2611
  so nothing enforces this at the database layer.