@spfn/auth 0.3.0-beta.7 → 0.3.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -136,17 +136,19 @@ real secret values out of band, never commit them.
136
136
  | `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
137
137
  | `SPFN_AUTH_BCRYPT_SALT_ROUNDS` | `.env.server` | — | default `12` (native bcrypt, off the event loop) |
138
138
  | `SPFN_AUTH_COOKIE_SECURE` | both | — | override Secure flag (defaults to `NODE_ENV==='production'`) |
139
+ | `SPFN_AUTH_CSRF` | `.env.local` | — | `off` \| `warn` \| `enforce`; unset behaves as `warn` — see [CSRF protection](#csrf-protection) |
139
140
  | `SPFN_AUTH_ADMIN_*` | `.env.server` | — | admin seeding (see below) |
140
141
  | `SPFN_AUTH_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | enables Google OAuth when both set |
141
142
  | `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) |
143
+ | `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
144
  | `SPFN_AUTH_KAKAO_CLIENT_ID` / `_CLIENT_SECRET` | `.env.server` | — | REST API key enables Kakao Login; secret is included when configured |
144
145
  | `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` |
146
+ | `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
147
  | `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` |
148
+ | `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
149
  | `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` |
150
+ | `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) |
151
+ | `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
152
  | `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
153
  | `SPFN_AUTH_APPLE_CLIENT_IDS` | `.env.server` | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
152
154
  | `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 |
@@ -184,6 +186,11 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
184
186
  | `confirmSignupLink` | POST `/_auth/signup/email/confirm` | public | exchange the link for a password-setup session |
185
187
  | `completeSignup` | POST `/_auth/signup/password` | setup session | set the password, which creates the account and signs in |
186
188
  | `login` | POST `/_auth/login` | public | password login + new session key |
189
+ | `startDeviceAuth` | POST `/_auth/device/start` | public | begin a device-code login — see [Device-code login](#device-code-login) |
190
+ | `pollDeviceAuth` | POST `/_auth/device/poll` | public | ask whether the request was answered; the approved answer *is* the login |
191
+ | `getDeviceAuthInfo` | POST `/_auth/device/info` | yes | what device is asking, so the approval screen can show it |
192
+ | `approveDeviceAuth` | POST `/_auth/device/approve` | yes | let the waiting device in |
193
+ | `denyDeviceAuth` | POST `/_auth/device/deny` | yes | refuse it |
187
194
  | `logout` | POST `/_auth/logout` | yes | revoke current key |
188
195
  | `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
189
196
  | `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
@@ -287,6 +294,100 @@ override it there to change the copy.
287
294
  `spfn_auth.signup_link_tokens`. Neither credential is recoverable from the database, and
288
295
  both are one-time: a link opens one setup session, and a setup session sets one password.
289
296
 
297
+ ### Device-code login
298
+
299
+ A way in for a device that has a screen but no comfortable keyboard — a TV, a console, a CLI
300
+ on a headless box. The new device shows a short code; the account owner types that code on a
301
+ device that is already signed in.
302
+
303
+ ```typescript
304
+ // On the new device — it has no key on file, so this call is public.
305
+ const { deviceCode, userCode, expiresAtMillis, intervalMillis } =
306
+ await authApi.startDeviceAuth.call({ body: {
307
+ publicKey, keyId, fingerprint, algorithm: 'ES256',
308
+ deviceName: 'Living room TV', platform: 'desktop',
309
+ } });
310
+
311
+ // Show `userCode` (XXXX-XXXX) on this device's screen, then poll every intervalMillis.
312
+ const answer = await authApi.pollDeviceAuth.call({ body: { deviceCode } });
313
+ // → { status: 'pending', intervalMillis }
314
+ // → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }
315
+ ```
316
+
317
+ ```typescript
318
+ // On the signed-in device — the user typed the code they read off the other screen.
319
+ const asking = await authApi.getDeviceAuthInfo.call({ body: { userCode } });
320
+ // → { deviceName?, platform?, fingerprintPrefix, requestedAtMillis, expiresAtMillis }
321
+
322
+ await authApi.approveDeviceAuth.call({ body: { userCode } }); // or denyDeviceAuth
323
+ ```
324
+
325
+ **There is no token handed over, because there is no token.** Every request in this system is
326
+ signed by the calling device's own key, so "logging a device in" means getting its public key
327
+ into `user_public_keys` under the right account — which is exactly what the winning poll does.
328
+ That is why the approved answer is the same shape `login` returns: from the client's side the
329
+ two ways in are indistinguishable.
330
+
331
+ - **Only ever show the code on the new device's screen.** The whole attack on this flow is
332
+ someone sending a victim a code and asking them to approve it — a support call, a chat
333
+ message, a "verify your account" email. A code that arrived any way other than off the
334
+ device in front of you is an attack. This is why `info` and `approve` answer with the
335
+ requesting device's name, platform and fingerprint prefix, and why an approval screen that
336
+ shows only the code is doing it wrong: it is asking the user to confirm a number they were
337
+ just told.
338
+ - **The device code is stored only as a SHA-256 hash**, like the ops-token and signup-link
339
+ secrets. It is returned once. A dump of `spfn_auth.device_authorizations` does not let its
340
+ reader finish anyone's login.
341
+ - **The user code is stored in the clear, and that is fine** — it authorizes nothing without
342
+ an approver who is already signed in. It is drawn from an alphabet with no `0`/`O` or
343
+ `1`/`I`/`L`, since it is read off one screen and typed on another.
344
+ - **A decision is made once.** Approve and deny move the record from `pending` and nowhere
345
+ else, so a second approval, a deny after an approve, or two approvals racing each other all
346
+ get `DeviceAuthAlreadyHandledError` (409) — a refusal is never undone.
347
+ - **The approval is one-shot.** The poll that registers the key spends the record in the same
348
+ statement that reads it, so of two polls arriving together exactly one registers the key and
349
+ the other is answered as if the code were unknown.
350
+ - **A spent code and a code that never existed answer identically** (`DeviceAuthNotFoundError`,
351
+ 404). Saying "that one was real, but it is used up" is the difference between guessing at
352
+ random and knowing a guess landed. Every route that accepts a code is rate limited for the
353
+ same reason: `start` and `poll` per IP, `info` / `approve` / `deny` per IP *and* per calling
354
+ account.
355
+ - **Expiry outranks state.** A code that sat past its TTL is expired whatever it says, so an
356
+ approval nobody collected in time registers nothing. The TTL travels in the statement that
357
+ moves the record, not only in the read before it, so a code cannot be spent by a poll that
358
+ read it a moment before it died.
359
+ - **A global revocation reaches the codes too.** `revoke-all`, a password change and a
360
+ deletion request each refuse the account's live device authorizations, so an approval nobody
361
+ collected cannot register a fresh key seconds after the user signed everything out — which
362
+ would hand one back to exactly the device they were cutting off. Revoking a single key,
363
+ logging out and rotating a key do not: those name one device, and the waiting one is not it.
364
+ - **The poll re-checks the account.** It is a login, so it refuses a suspended or
365
+ pending-deletion account with the same errors `/_auth/login` does. Approval and collection
366
+ are separate moments, and what the account is when the key is registered is what counts.
367
+ - **`start` bounds what it stores.** It is the one route that takes key material from a caller
368
+ who cannot authenticate, so `publicKey`, `keyId` and `fingerprint` carry length limits —
369
+ generous next to a real key (an RSA-2048 SPKI is 392 base64 characters against a 2048 limit)
370
+ and small next to the megabyte that would otherwise sit in a table no job clears.
371
+ - **Clock skew cannot affect this.** Every timestamp in the decision is the server's. The
372
+ `expiresAtMillis` in the start response is for the waiting device's countdown display, and
373
+ nothing the client believes about the time reaches the server's judgement.
374
+
375
+ Two knobs, both announced to the waiting device in the start response and therefore resolved
376
+ at lifecycle time rather than read per call:
377
+
378
+ ```typescript
379
+ createAuthLifecycle({
380
+ deviceAuth: {
381
+ ttlMs: 10 * 60 * 1000, // how long a code lives. default 10 minutes
382
+ intervalMs: 5000, // poll interval the server asks for. default 5s
383
+ },
384
+ })
385
+ ```
386
+
387
+ No job sweeps the table. Rows are judged by `expiresAt` whenever they are read or moved, so a
388
+ stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
389
+ not run out.
390
+
290
391
  ### Registered devices (key management)
291
392
 
292
393
  Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
@@ -326,6 +427,11 @@ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // every
326
427
  > naming the cause. Proof-bearing auth operations are shaped this way; the unproven,
327
428
  > bodyless `core.time` synchronization prerequisite is the explicit exception.
328
429
 
430
+ - **A key must be the type its algorithm names.** A P-256 SPKI declared `RS256`, an RSA key
431
+ declared `ES256`, and a curve other than P-256 declared `ES256` are each refused 400 with
432
+ `KeyAlgorithmMismatchError` on register, login, rotate and device start — the algorithm is
433
+ stored beside the key and read back at proof verification, so a mismatch accepted at
434
+ enrollment would surface only once the device already believed it was enrolled.
329
435
  - **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
330
436
  The list exists to recognise a device and point at it; the full fingerprint is what a native
331
437
  sign-in sends as its nonce, not a label.
@@ -337,6 +443,10 @@ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // every
337
443
  - **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
338
444
  "sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
339
445
  only as a side effect of changing a password, which nobody does for that reason.
446
+ - **It also refuses device-code approvals still in flight**, in both modes, because an approved
447
+ code is a key that has not been handed out yet: the next poll would register a fresh active one
448
+ and undo the sign-out. `revokedCount` still counts keys only — a code nobody collected was
449
+ never a session. See [Device-code login](#device-code-login).
340
450
  - **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
341
451
  the answer is only ever "not yours" and reveals nothing about other accounts.
342
452
  - **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
@@ -529,9 +639,25 @@ process), which differs from the API process in a split deployment — the callb
529
639
  matches every `spfn_oauth_csrf*` cookie candidate against the state nonce, so no PORT
530
640
  coordination is needed.
531
641
 
642
+ An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` is checked when the server boots, because the
643
+ value used to be read lazily on the first OAuth request and a wrong one surfaced much later as
644
+ a CSRF refusal nobody traced back to it. A value that does not parse, or whose origin is not the
645
+ web app origin, or whose path is not `/_auth/oauth/<provider>/callback`, refuses to start — one
646
+ error naming every offending variable:
647
+
648
+ ```
649
+ SPFN_AUTH_GOOGLE_REDIRECT_URI must be on the web app origin (http://localhost:3790) at
650
+ /_auth/oauth/google/callback: the callback's CSRF cookie is host-only and /_auth/* is forwarded
651
+ to the API by the app's rewrite. Unset it to use the default, fix the origin, or set
652
+ SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off for a deployment that deliberately terminates the
653
+ callback elsewhere.
654
+ ```
655
+
532
656
  One caveat: the direct `POST /_auth/oauth/start` flow (no Next.js interceptor) sets its CSRF
533
- cookie on the **API host**. If you use that flow in a split deployment, set
534
- the corresponding provider redirect URI explicitly to the API host callback instead.
657
+ cookie on the **API host**. If you use that flow in a split deployment, set the corresponding
658
+ provider redirect URI explicitly to the API host callback **and**
659
+ `SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off` — that is the one deployment the check is wrong
660
+ about, and `off` is the only value that disables it.
535
661
 
536
662
  ### Native social sign-in (mobile / web id_token)
537
663
 
@@ -779,6 +905,134 @@ export default async function AdminPage()
779
905
  Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
780
906
  `hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
781
907
 
908
+ ## CSRF protection
909
+
910
+ Cookie-authenticated mutations carry a CSRF token by default. Nothing to write: the
911
+ Next.js proxy issues the token with the session and the api client sends it back.
912
+
913
+ **What it protects.** The session cookie is `SameSite=Lax`, which already blocks the
914
+ classic cross-site form POST. What remains is what Lax does not cover: a sibling
915
+ subdomain that can write cookies on your parent domain (an XSS on `blog.example.com`
916
+ against `app.example.com`), browsers that predate or mis-implement Lax, and a domain
917
+ layout that drifts into `SameSite=None` later. This closes those.
918
+
919
+ **What it does not protect.** Nothing here helps against XSS on your own origin.
920
+ Script running on your origin can read the token cookie and call your API as the
921
+ user — that is true of every CSRF scheme, and no token design changes it. Same-origin
922
+ XSS is out of scope; Content-Security-Policy and output escaping are the answer to it.
923
+
924
+ ### How it works
925
+
926
+ - On login, OAuth finalize, key rotation and every session renewal, the proxy sets
927
+ `spfn_csrf` — a readable (non-HttpOnly) cookie holding only an HMAC of the session's
928
+ key id, keyed by a subkey derived from `SPFN_AUTH_SESSION_SECRET`. No new variable,
929
+ and the raw session secret is never used as the token key. Sessions that predate
930
+ the feature get one on their first authenticated response, so upgrading does not
931
+ require anyone to sign in again.
932
+ - The api client mirrors the cookie into the `x-spfn-csrf` header on **every** RPC call,
933
+ GET-shaped ones included — see "Which requests are checked" for why it cannot narrow
934
+ that itself. Where the header is *checked* is the proxy's decision, not the client's.
935
+ - The proxy **recomputes** the expected value from the session it just unsealed and
936
+ compares it to the header, in constant time. It never compares the cookie to the
937
+ header — that is the classic double-submit weakness, and it is exactly what a
938
+ sibling subdomain defeats by tossing a cookie it chose. A tossed cookie fails here.
939
+ - The token derives from the session key id, so rotating the key invalidates it. The
940
+ proxy reissues the cookie in the same response that rotates or renews the session.
941
+
942
+ The check runs in the proxy, not the backend, because only the proxy knows the
943
+ request's credential was ambient: it turns the session cookie into a short-lived
944
+ bearer JWT, so the backend sees `scheme:'bearer'` for cookie callers and for genuine
945
+ bearer clients alike.
946
+
947
+ ### Which requests are checked
948
+
949
+ Only requests the proxy authenticates from the session cookie, and only when the
950
+ resolved **route** method is not GET/HEAD/OPTIONS.
951
+
952
+ Route method, not the method the browser used to reach the proxy. The api client picks
953
+ its wire method from whether the input has a body, and holds no route map — that is the
954
+ point of "no metadata codegen required" — so a mutation with nothing to send travels as
955
+ GET. `logout` is `POST /_auth/logout`; `revokeOpsToken` is
956
+ `DELETE /_auth/ops-tokens/:id`, called with only a path param. Both are `GET` on the
957
+ wire and both are forwarded as the route's real method. A client that withheld the
958
+ header on GET-shaped calls would therefore 403 them under `enforce`, which is why the
959
+ contract is "every call carries it" and the proxy alone decides where it is checked.
960
+ Gating in the proxy on the wire method would be worse still: a cross-site top-level GET
961
+ navigation *does* carry a `SameSite=Lax` cookie, so every mutation would stay reachable
962
+ that way.
963
+
964
+ Untouched, by construction: requests with no session, direct-to-backend bearer
965
+ clients, `clientProofV1` mobile callers, machine and ops tokens. None of them pass
966
+ through this code. A request without a session is answered exactly as before (the
967
+ backend returns 401) — a CSRF refusal only ever answers an authenticated request, so
968
+ the refusal itself cannot tell an anonymous caller whether anyone is signed in.
969
+
970
+ ### Modes
971
+
972
+ | Mode | Behaviour |
973
+ |---|---|
974
+ | `off` | No check. |
975
+ | `warn` | **Default.** Allows the request, logs one line per request that would be refused. |
976
+ | `enforce` | Refuses with `403 {"error":"Forbidden","message":"CSRF token missing or invalid"}`. |
977
+
978
+ Existing apps get signal before breakage: unset means `warn`. Watch for
979
+ `@spfn/auth:interceptor:csrf` lines, then switch on. Apps scaffolded by `spfn init`
980
+ start at `enforce`.
981
+
982
+ ```bash
983
+ # .env.local — read by the Next.js process, where the proxy runs
984
+ SPFN_AUTH_CSRF=enforce
985
+ ```
986
+
987
+ ```typescript
988
+ import { configureAuth } from '@spfn/auth/server';
989
+
990
+ configureAuth({
991
+ csrf: {
992
+ mode: 'enforce',
993
+ // Exact backend route paths, params already substituted — not /api/rpc/… URLs.
994
+ // For endpoints a browser session never calls, e.g. webhook receivers that
995
+ // authenticate themselves by signature. An exempt path is unprotected for
996
+ // cookie callers too, so list only endpoints that carry their own auth.
997
+ exemptPaths: ['/webhooks/stripe'],
998
+ },
999
+ });
1000
+ ```
1001
+
1002
+ `configureAuth` wins over the environment variable. `enforce` and `warn` both need
1003
+ `SPFN_AUTH_SESSION_SECRET` — sessions need it anyway — and refuse rather than quietly
1004
+ passing everything if it is missing.
1005
+
1006
+ ### If a request is refused
1007
+
1008
+ A refusal in a running app almost always means the token cookie is gone or stale while
1009
+ the session is not — cleared by hand or by an extension, or a session that predates this
1010
+ feature. Rotation is not a cause: the response that rotates the key reissues the cookie
1011
+ in the same breath, and one browser has one jar, so other tabs pick the new value up
1012
+ with it.
1013
+
1014
+ Two things repair it, and both are mechanical:
1015
+
1016
+ - **The 403 carries the fix.** The proxy is the one emitting the refusal, so it sets a
1017
+ fresh `spfn_csrf` on that very response. A browser that repeats the mutation succeeds.
1018
+ The refusal is otherwise unchanged — same status, same body.
1019
+ - **Any authenticated response reissues a wrong one.** A response whose request arrived
1020
+ with no CSRF cookie, or with one that no longer matches the session, queues the
1021
+ correct value. A cookie that is merely *present* is not taken as proof it is right.
1022
+
1023
+ **The client does not retry a refused call**, so a user sees one failure before the
1024
+ repaired state takes effect — the framework fixes the browser, not the click.
1025
+
1026
+ **Limitation — calls made from the server.** A Server Component cannot set cookies at
1027
+ all, and Next.js does not forward `Set-Cookie` from a fetch the api client made on the
1028
+ server to the browser. So neither repair reaches the jar when the refused call came from
1029
+ a Server Component, a Server Action or a Route Handler; the next browser-originated
1030
+ request through the proxy is what heals it. Server-side callers otherwise need no
1031
+ change: the api client reads the whole jar through `next/headers`, and an explicit
1032
+ `cookies` option merges over that rather than replacing it. Only a caller that
1033
+ hand-builds a jar somewhere `cookies()` cannot be reached — build time, static
1034
+ generation — has to include the CSRF cookie itself.
1035
+
782
1036
  ## How do I define roles and permissions?
783
1037
 
784
1038
  Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
@@ -871,6 +1125,12 @@ authRegisterEvent.subscribe(async ({ userId, email, provider, metadata }) =>
871
1125
  });
872
1126
  ```
873
1127
 
1128
+ `authLoginEvent`'s `provider` is `'email'`, `'phone'`, a social provider, or `'device'` — the
1129
+ last one being a [device-code login](#device-code-login), where the account was proven on
1130
+ another device that was already signed in and no credential was presented here.
1131
+ `authRegisterEvent` does not accept `'device'`: a device-code request can only ever be
1132
+ approved by an account that already exists, so it is never a signup.
1133
+
874
1134
  Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `InvitationCreatedPayload`,
875
1135
  `InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
876
1136
  `AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
@@ -1107,6 +1367,7 @@ Every operation in the exported bundle carries `since` — the contract version
1107
1367
  | `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
1108
1368
  | `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
1109
1369
  | `core.time` | 0.9.0 |
1370
+ | `auth.device.start`, `auth.device.poll`, `auth.device.info`, `auth.device.approve`, `auth.device.deny` | 0.10.0 |
1110
1371
 
1111
1372
  - **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
1112
1373
  contract version passes or refuses the whole surface, so these three fields change no verdict here.
@@ -1242,6 +1503,145 @@ x-acme-service-token: <the app's own credential>
1242
1503
  The field stays informational: downstream permission and tenant code takes one principal shape and
1243
1504
  never branches on how it was produced.
1244
1505
 
1506
+ ## Machine principals (`registerMachineVerifier`)
1507
+
1508
+ A machine credential is issued by a service to a non-interactive process, and its subject is
1509
+ an account or a tenant, not a person. `AuthContext` cannot hold one — it requires a `users`
1510
+ row — and resolving a machine token to its owning user is worse than the type error: it makes
1511
+ the machine's request indistinguishable from that user's own session.
1512
+
1513
+ So a machine principal never enters `AuthContext`. It lives in its own context key, is read by
1514
+ its own helper, and is admitted by its own middleware:
1515
+
1516
+ ```typescript
1517
+ import { machineAuth, requireMachineScope, getMachinePrincipal } from '@spfn/auth/server';
1518
+
1519
+ export const ingest = route.post('/v1/ingest')
1520
+ .use([machineAuth, requireMachineScope('events:write')])
1521
+ .handler(async (c) =>
1522
+ {
1523
+ const { subjectType, subjectId } = getMachinePrincipal(c.raw)!;
1524
+ // subjectType: 'account' | 'service' | whatever the verifier named
1525
+ });
1526
+ ```
1527
+
1528
+ `getAuth(c)` on that route returns nothing, because nothing put a user there. That is the
1529
+ whole design: a machine request cannot impersonate a user session, not because a check
1530
+ forbids it but because no code path leads there.
1531
+
1532
+ **Ownership is not authentication.** Who issued a machine token, who owns it, and who may
1533
+ revoke or audit it are the registrant's data-level concerns — put the token id in `claims` and
1534
+ answer them from your own tables. What the request *acts as* is the token's own subject and
1535
+ scopes, and nothing here resolves a machine subject to a user.
1536
+
1537
+ ### Registering a verifier
1538
+
1539
+ A verifier claims one namespace, by a raw `tokenPrefix` (for an opaque secret, the
1540
+ `spfn_ops_` shape) or by a `kidPrefix` on the unverified JOSE header of a JWS. Register at
1541
+ boot, before the first request:
1542
+
1543
+ ```typescript
1544
+ import { registerMachineVerifier } from '@spfn/auth/server';
1545
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
1546
+
1547
+ const RUNTIME_JWKS = createRemoteJWKSet(new URL('https://issuer.example.com/.well-known/jwks.json'));
1548
+
1549
+ registerMachineVerifier({
1550
+ id: 'runtimeJwsV1',
1551
+ match: { kidPrefix: 'machine:runtime:' },
1552
+ verify: async (token) =>
1553
+ {
1554
+ const { payload } = await jwtVerify(token, RUNTIME_JWKS, { issuer: 'https://issuer.example.com' });
1555
+
1556
+ return {
1557
+ subjectType: 'account',
1558
+ subjectId: String(payload.sub),
1559
+ scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
1560
+ claims: { tokenId: payload.jti },
1561
+ scheme: 'runtimeJwsV1',
1562
+ };
1563
+ },
1564
+ });
1565
+ ```
1566
+
1567
+ The request carries it as an ordinary bearer token — no new wire format, and the
1568
+ profile-header channel is not involved:
1569
+
1570
+ ```http
1571
+ POST /v1/ingest
1572
+ Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hY2hpbmU6cnVudGltZTo...
1573
+ ```
1574
+
1575
+ - **Namespace your kids.** `machine:` is the convention this package documents, and a user
1576
+ session JWT never carries that shape. The prefix is what tells the two apart before either
1577
+ is verified.
1578
+ - **Conflicting discriminators are refused at registration** — a duplicate `id`, a duplicate
1579
+ prefix, or a prefix that would shadow an already-registered one (`machine:` swallowing
1580
+ `machine:runtime:`). Two verifiers one token could match would make admission depend on
1581
+ registration order, so that is a boot-time error rather than something the dispatch
1582
+ resolves per request.
1583
+ - **A `tokenPrefix` claims every token that starts with it**, and `authenticate` consults the
1584
+ registry before it decodes anything. A prefix a user's JWT could begin with (`ey…`) would
1585
+ therefore refuse every user session — pick a prefix no other credential on your surface
1586
+ shares, as `spfn_ops_` does.
1587
+ - **Register at boot, before the first request.** The registry is module state read on every
1588
+ dispatch, so a verifier registered later is simply a verifier the requests before it did
1589
+ not have. There is no unregistration and no reset — the same contract, and the same reason,
1590
+ as [`registerAuthProfile`](#custom-auth-profiles-registerauthprofile).
1591
+ - **Registering nothing costs nothing.** With no verifier registered, `authenticate` is two
1592
+ array-length checks away from what it was. The unverified JOSE header peek happens only
1593
+ once a `kidPrefix` verifier exists.
1594
+ - **`scheme` is the registry's answer**, not the verifier's: whatever a verifier returns
1595
+ there, the principal carries the `id` that admitted it, so an audit trail cannot be made to
1596
+ name the wrong verifier.
1597
+
1598
+ ### The case table
1599
+
1600
+ | credential ↓ route → | `authenticate` (user) | `machineAuth` | `optionalAuth` |
1601
+ |---|---|---|---|
1602
+ | user bearer JWT | ✓ user (unchanged) | 401 | ✓ user (unchanged) |
1603
+ | machine token, registered namespace, valid | 401 — refused before the token is decoded | ✓ sets `machinePrincipal` | 401 |
1604
+ | machine token, registered namespace, verifier rejects | 401 | 401 | 401 |
1605
+ | machine-shaped token, unregistered namespace | 401 (the existing invalid-token path) | 401 | continues, no auth |
1606
+ | profile header + any Bearer | `PROFILE_REJECTED` (unchanged) | `PROFILE_REJECTED` | `PROFILE_REJECTED` |
1607
+ | nothing | 401 (unchanged) | 401 | continues, no auth |
1608
+ | valid principal, missing scope | — | 403 | — |
1609
+ | valid principal, sufficient scope | — | 200 | — |
1610
+
1611
+ Every 401 above is one message. Whether a namespace is registered, whether a presented token
1612
+ was ever valid, and whether a verifier rejected it are not inferable from the answer — the
1613
+ same non-disclosure rule the [ops-token](#ops-tokens-spfn-ops) table keeps. 403 is reserved
1614
+ for scope, where the caller is already authenticated; `requireMachineScope` matches scopes
1615
+ exactly and has no wildcard, and it fails closed with a 401 if it runs without `machineAuth`
1616
+ before it.
1617
+
1618
+ A verifier that throws something other than a refusal — a bug in registrant code — is the
1619
+ same generic 401 on the wire, with the real error logged. Never a 500 carrying registrant
1620
+ internals, and never a silent pass.
1621
+
1622
+ The last row of the unregistered-namespace case is the one asymmetry: a token in a namespace
1623
+ nobody registered is not a machine credential as far as this package can tell, so under
1624
+ `optionalAuth` it gets what any unusable bearer token has always got. A token in a
1625
+ *registered* namespace is refused there, because refusing it is the difference between
1626
+ "presented the wrong credential" and "presented none".
1627
+
1628
+ The non-disclosure above is therefore an `authenticate` and `machineAuth` property, not an
1629
+ `optionalAuth` one: on an `optionalAuth` route a caller can tell a registered namespace from
1630
+ an unregistered one, because one is refused and the other is served anonymously. Closing that
1631
+ gap would mean refusing every unusable bearer token on those routes — a change to behaviour
1632
+ that predates machine principals, and a worse trade than the inference it prevents. Mount
1633
+ `machineAuth` where the distinction matters.
1634
+
1635
+ ### Issuance is yours
1636
+
1637
+ This package verifies machine tokens; it does not mint them. Issuance, rotation, and
1638
+ revocation belong to whoever owns the subject — keep the tokens short-lived, and prefer a
1639
+ signature you can verify offline (`kidPrefix` + JWKS) over a secret you must look up.
1640
+
1641
+ `opsTokenAuth` is the built-in instance of exactly this pattern, hand-written for one
1642
+ credential before the registry existed: its own context key (`opsToken`), its own scope guard,
1643
+ `AuthContext` never set. It keeps its own implementation and is not registered here.
1644
+
1245
1645
  ## Account Deletion & Recovery
1246
1646
 
1247
1647
  Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
@@ -1355,7 +1755,9 @@ deploy.
1355
1755
  Almost always the callback origin. The CSRF check is a double-submit against a host-only
1356
1756
  cookie set on your **web app** host, so the provider must return to the web app origin, and
1357
1757
  the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
1358
- callback 404s — including in local dev. Details in
1758
+ callback 404s — including in local dev. An explicit `SPFN_AUTH_<PROVIDER>_REDIRECT_URI` on the
1759
+ wrong origin or path no longer gets that far: it fails at boot with a message naming the
1760
+ variable. Details in
1359
1761
  [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
1360
1762
 
1361
1763
  **Does the server hold my users' private keys?**
@@ -398,7 +398,7 @@ declare function getClientProofReplayStore(): ClientProofReplayStore;
398
398
  */
399
399
 
400
400
  interface ContractOperation {
401
- id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.keys.rotate' | 'auth.keys.list' | 'auth.keys.revoke' | 'auth.keys.revokeAll' | typeof CORE_TIME_OPERATION_ID;
401
+ id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.keys.rotate' | 'auth.keys.list' | 'auth.keys.revoke' | 'auth.keys.revokeAll' | 'auth.device.start' | 'auth.device.poll' | 'auth.device.info' | 'auth.device.approve' | 'auth.device.deny' | typeof CORE_TIME_OPERATION_ID;
402
402
  method: 'GET' | 'POST';
403
403
  path: string;
404
404
  /**
@@ -411,7 +411,11 @@ interface ContractOperation {
411
411
  requiresSession: boolean;
412
412
  /** Absent only when the operation has no request body. */
413
413
  requestType?: string;
414
- responseType: string;
414
+ /**
415
+ * Absent only when the operation answers with no body — a 204, which has
416
+ * nothing for a consumer to decode and therefore names no type.
417
+ */
418
+ responseType?: string;
415
419
  summary: string;
416
420
  /**
417
421
  * The contract version this operation first appeared in. Required, so an
@@ -460,16 +464,23 @@ declare const IMPORTED_CORE_TIME_CONTRACT: {
460
464
  declare const CORE_PREREQUISITE_OPERATIONS: readonly ContractOperation[];
461
465
  declare const CONTRACT_OPERATIONS: readonly ContractOperation[];
462
466
  /**
463
- * The `/_auth` surface exported into the mobile contract: enrollment, login
464
- * and key rotation. These are ordinary SPFN REST routes, not canonical-JSON
465
- * operations — the dev handler never serves them, and their wire rules are
466
- * the `restOperations` section of the bundle, not `canonicalJson`.
467
- *
468
- * The three `authProfile: 'none'` operations are the unproven class: they are
469
- * accepted with neither proof headers nor a session header, because they are
470
- * how a client obtains a key in the first place. `auth.keys.rotate` requires
471
- * an authenticated caller (a clientProofV1 proof on this surface); an
472
- * unproven call to it is refused like any failed admission.
467
+ * The `/_auth` surface exported into the mobile contract: enrollment, login,
468
+ * key rotation and device-code login. These are ordinary SPFN REST routes, not
469
+ * canonical-JSON operations — the dev handler never serves them, and their wire
470
+ * rules are the `restOperations` section of the bundle, not `canonicalJson`.
471
+ *
472
+ * The `authProfile: 'none'` operations are the unproven class: they are accepted
473
+ * with neither proof headers nor a session header, because they are how a client
474
+ * obtains a key in the first place. `auth.keys.rotate` requires an authenticated
475
+ * caller (a clientProofV1 proof on this surface); an unproven call to it is
476
+ * refused like any failed admission.
477
+ *
478
+ * Device-code login is in both classes at once, because two devices run it. The
479
+ * device being let in calls `start` and `poll` unproven — it has no registered
480
+ * key to sign with, and getting one is the point of the flow. The approver calls
481
+ * `info`, `approve` and `deny` proven, from a device that is already signed in,
482
+ * which is what lets the server read the approving account from the caller
483
+ * rather than from the request body.
473
484
  */
474
485
  declare const AUTH_SURFACE_OPERATIONS: readonly ContractOperation[];
475
486
  /** The body is canonical JSON but not the declared request type. */