@spfn/auth 0.3.0-beta.23 → 0.3.0-beta.25

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 (35) hide show
  1. package/README.md +328 -7
  2. package/dist/client-proof.d.ts +10 -1
  3. package/dist/client-proof.js +136 -11
  4. package/dist/client-proof.js.map +1 -1
  5. package/dist/client.d.ts +101 -1
  6. package/dist/client.js +65 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/config.d.ts +122 -0
  9. package/dist/config.js +53 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/errors.d.ts +159 -3
  13. package/dist/errors.js +95 -2
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.d.ts +52 -12
  16. package/dist/index.js +104 -3
  17. package/dist/index.js.map +1 -1
  18. package/dist/{machine-principals-CaEFq61K.d.ts → machine-principals-CdEgxOB1.d.ts} +2049 -771
  19. package/dist/nextjs/api.js +329 -37
  20. package/dist/nextjs/api.js.map +1 -1
  21. package/dist/nextjs/server.d.ts +59 -24
  22. package/dist/nextjs/server.js +105 -11
  23. package/dist/nextjs/server.js.map +1 -1
  24. package/dist/server.d.ts +415 -332
  25. package/dist/server.js +2933 -1545
  26. package/dist/server.js.map +1 -1
  27. package/dist/{session-Dfwu5g2W.d.ts → session-BbhAGZtA.d.ts} +57 -1
  28. package/dist/{types-DYyhze28.d.ts → types-CTdoTOxM.d.ts} +24 -1
  29. package/migrations/20260918184037_happy_mordo/migration.sql +4 -0
  30. package/migrations/20260918184037_happy_mordo/snapshot.json +6000 -0
  31. package/migrations/20260918184152_dear_rictor/migration.sql +3 -0
  32. package/migrations/20260918184152_dear_rictor/snapshot.json +6039 -0
  33. package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
  34. package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
  35. package/package.json +1 -1
package/README.md CHANGED
@@ -173,6 +173,12 @@ real secret values out of band, never commit them.
173
173
  | `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` / `_RECENT_AUTH_MINUTES` | `.env.server` | — | defaults `300` / `10` — see [Passkeys](#passkeys-webauthn) |
174
174
  | `SPFN_AUTH_MFA_ISSUER` | `.env.server` | — | name the authenticator app files the account under; defaults to the passkey relying-party name, then the app URL host — see [Second factor](#second-factor-mfa) |
175
175
  | `SPFN_AUTH_MFA_STEP_UP_MINUTES` | `.env.server` | — | default `10`; how recently an enrolled account's device must have proved its second factor for a sensitive change — see [Second factor](#second-factor-mfa) |
176
+ | `SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES` | `.env.server` | — | default `10`; how long a new-device step-up challenge stays spendable — see [Step-up on a new device](#step-up-on-a-new-device) |
177
+ | `SPFN_AUTH_MFA_CONFIRM_PATH` | `.env.server` | — | default `/auth/mfa`; app page the OAuth callback handler sends a browser to when a social sign-in needs a second factor |
178
+ | `SPFN_AUTH_BOUND_KEY_TTL_HOURS` | `.env.server` | — | default `24`; how long a passkey-bound session key lives — see [Session binding](#session-binding) |
179
+ | `SPFN_AUTH_BOUND_KEY_RENEW_GRACE_HOURS` | `.env.server` | — | default `168`; how long past expiry a bound key may still be renewed. Past it, sign in again |
180
+ | `SPFN_AUTH_CONCURRENT_USE_WINDOW_MS` | `.env.server` | — | default `300000`; how close two sightings from two addresses must be to raise `concurrentUseAtMillis` |
181
+ | `SPFN_AUTH_SESSION_RENEW_PATH` | `.env.local` | — | default `/auth/renew`; the page `RequireAuth` sends a bound session whose key ran out |
176
182
  | `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
177
183
 
178
184
  Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
@@ -224,11 +230,18 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
224
230
  | `mfaStatus` | GET `/_auth/mfa/status` | yes | `{ enrolled, methods, recoveryCodesRemaining }`; no secret |
225
231
  | `mfaStepUp` | POST `/_auth/mfa/step-up` | yes | re-prove the second factor on this device |
226
232
  | `mfaStepUpOptions` | POST `/_auth/mfa/step-up/options` | yes | options for a step-up by passkey |
233
+ | `mfaVerify` | POST `/_auth/mfa/verify` | public | finish a sign-in that answered `202 { mfaRequired: true }` — see [Step-up on a new device](#step-up-on-a-new-device) |
234
+ | `mfaVerifyOptions` | POST `/_auth/mfa/verify/options` | public | options for finishing that sign-in with a passkey |
227
235
  | `logout` | POST `/_auth/logout` | yes | revoke current key |
228
236
  | `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
229
237
  | `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
230
238
  | `revokeKey` | POST `/_auth/keys/revoke` | yes | sign one device out |
231
239
  | `revokeAllKeys` | POST `/_auth/keys/revoke-all` | yes | sign every device out (spares the caller by default) |
240
+ | `setSessionBinding` | POST `/_auth/session/binding` | yes | turn session binding on or off — see [Session binding](#session-binding) |
241
+ | `getSessionBinding` | GET `/_auth/session/binding` | yes | whether it is on, and when this session's key expires |
242
+ | `sessionBindingDisableOptions` | POST `/_auth/session/binding/disable/options` | yes | the challenge that proves it is you before turning it off |
243
+ | `sessionRenewOptions` | POST `/_auth/session/renew/options` | public | begin renewing a bound session key |
244
+ | `sessionRenewVerify` | POST `/_auth/session/renew/verify` | public | verify the assertion; answers exactly as `login` |
232
245
  | `changePassword` | PUT `/_auth/password` | yes | change password |
233
246
  | `getAuthSession` | GET `/_auth/session` | yes | current session/user |
234
247
  | `issueOneTimeToken` | POST | yes | short-lived token (e.g. SSE handshake) |
@@ -249,7 +262,48 @@ revisiting that decision.
249
262
  Auth uses **asymmetric, client-signed JWTs**: the client generates an ES256/RS256 keypair,
250
263
  sends the public key on register/login, signs request JWTs locally, and the server verifies
251
264
  with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
252
- Keys expire after 90 days — rotate with `rotateKey`.
265
+ Keys expire after 90 days — rotate with `rotateKey`, which starts the ninety days again. A key
266
+ bound to a passkey is the one exception: it lives for hours and a rotation carries its expiry over
267
+ rather than resetting it, because only `session/renew` may move that window — see
268
+ [Session binding](#session-binding).
269
+
270
+ ### Migration — narrow a sign-in on `mfaRequired` before reading `userId`
271
+
272
+ **Breaking in `@spfn/auth` 0.3.0-beta.25 / mobile contract 0.13.0.** A sign-in no
273
+ longer always answers with a session. An account that enrolled a second factor and
274
+ signs in from a device the account has never seen gets `202` and a challenge
275
+ instead, and the key it registered stays inactive until that challenge is spent —
276
+ see [Second factor](#second-factor-mfa).
277
+
278
+ So `LoginResult` carries one new required field, `mfaRequired`, and every field it
279
+ carried before is now optional. It is still **one** type rather than a union:
280
+ `authApi.login` infers its result from that declaration, and a union would make
281
+ every `result.userId` in your app a compile error with no way to narrow it that
282
+ was available in 0.12.x. Narrow on the discriminant:
283
+
284
+ ```typescript
285
+ const result = await authApi.login.call({ body: { email, password } });
286
+
287
+ if (result.mfaRequired)
288
+ {
289
+ // No session yet. result.challenge is { secret, expiresAtMillis }.
290
+ router.push('/auth/mfa');
291
+
292
+ return;
293
+ }
294
+
295
+ console.log(result.userId); // string, from here on
296
+ ```
297
+
298
+ The same reshape applies to `authApi.oauthNative` (`OauthNativeResult`), to
299
+ `completePasswordReset`, and to the approved branch of `pollDeviceAuth` — which
300
+ carries `mfaRequired: false` and can never carry anything else, since a
301
+ device-code approval is itself a second factor.
302
+
303
+ Nothing changes for an account with no second factor: every one of those calls
304
+ answers `200` with `mfaRequired: false` and exactly the fields it always did.
305
+ In the Next.js proxy nothing changes for your code at all — the interceptors
306
+ handle the 202 and the pending cookie themselves.
253
307
 
254
308
  ### Verified-email signup
255
309
 
@@ -529,11 +583,15 @@ Keys are per-device, so a login never revokes the previous key and they accumula
529
583
  `listKeys` / `revokeKey` / `revokeAllKeys` are what let the account owner see what accumulated and
530
584
  cut off anything they no longer recognise.
531
585
 
586
+ A key still waiting on a [second factor](#step-up-on-a-new-device) is in neither list. It
587
+ cannot sign for anything, so it is not a device; and nobody signed it out, so it is not a
588
+ revoked one either. A global revocation deletes it outright rather than revoking it.
589
+
532
590
  ```typescript
533
591
  const { keys } = await authApi.listKeys.call({ body: {} });
534
592
  // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
535
593
  // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis?,
536
- // registeredIp?, registeredUserAgent? }]
594
+ // registeredIp?, registeredUserAgent?, binding?, concurrentUseAtMillis? }]
537
595
 
538
596
  await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
539
597
  ```
@@ -602,6 +660,14 @@ Rotation carries the replaced key's label over unless the client sends a new one
602
660
  registered before the columns existed; the literal string `unknown` is never stored. They are
603
661
  unauthenticated display material, spoofable on any request that does not come through a verified
604
662
  proxy, so render them and decide nothing by them. Mobile contract 0.11.0.
663
+ - **`binding` says the key is tied to a passkey**, and is absent on every key that is not — which
664
+ is every key on an account that did not turn [session binding](#session-binding) on. A bound key
665
+ expires in hours and only a passkey assertion renews it.
666
+ - **`concurrentUseAtMillis` is when this key was last seen from two addresses at once**, inside
667
+ `SPFN_AUTH_CONCURRENT_USE_WINDOW_MS`. Absent when that has never been observed, which is the
668
+ ordinary state. A signal to show, never a refusal — addresses change legitimately — and the
669
+ addresses themselves are never returned. Meaningful only where proxy-guard is configured. Mobile
670
+ contract 0.12.0.
605
671
 
606
672
  All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
607
673
  `auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
@@ -1082,6 +1148,99 @@ Two sign-ins deliberately produce a session with no verification of its own: a
1082
1148
  `POST /_auth/mfa/step-up` is for. Marking a passkey as a second factor is likewise not proving
1083
1149
  it, so the device that marks one steps up before it may change the second factor again.
1084
1150
 
1151
+ #### Step-up on a new device
1152
+
1153
+ The moment the feature exists for. An **enrolled** account signing in from a device it has
1154
+ never seen does not get a session — it gets a challenge, and the device key it registered
1155
+ stays inactive until that challenge is spent. A password phished from somebody is no longer
1156
+ enough to hold their account.
1157
+
1158
+ ```
1159
+ POST /_auth/login → 202 { mfaRequired: true, challenge: { secret, expiresAtMillis } }
1160
+ the key is registered, is_active = false, and nothing else moved
1161
+ POST /_auth/mfa/verify → 200 the LoginResult the sign-in would have given
1162
+ { challenge, code } plus keyId and challengeHash, for the proxy
1163
+ { challenge, recoveryCode }
1164
+ { challenge, response } options from POST /_auth/mfa/verify/options
1165
+ ```
1166
+
1167
+ Four channels stop: **password**, **oauth** (web), **oauth-native** and **password-reset** —
1168
+ the four where one stolen credential would otherwise be enough. A device-code approval and a
1169
+ passkey sign-in do not, because each already carried a second proof; nor does a key rotation,
1170
+ a renewal, or any path that is creating the account. A brand-new social account is not stepped
1171
+ up either, and needs no exemption to say so: an account that was written a moment ago has
1172
+ nothing enrolled.
1173
+
1174
+ **A 202 moves nothing.** No login event, no new-device event, no `lastLoginAt`. All three are
1175
+ held on the challenge row and fire together at `verify`, with the original channel — so the
1176
+ owner's record of their own sign-ins stays a record of sign-ins that happened.
1177
+
1178
+ **Until it is verified, the key does not exist** to anything the owner can see: `authenticate`
1179
+ refuses it, `optionalAuth` reads the caller as anonymous, and `listKeys` omits it in both
1180
+ modes. Every global revocation — `revoke-all`, a password change, the sign-out-everywhere
1181
+ link, a password reset — **deletes** it and kills its challenge in the same statement, so the
1182
+ owner who reacts to an unexpected prompt by signing out everywhere really has.
1183
+
1184
+ The challenge is 32 random bytes. Only its hash is stored, so a guess reaches no row and
1185
+ cannot touch anybody's attempt counter; it is single use, it lives
1186
+ `SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES` (default 10), it dies with the account's key generation,
1187
+ and five wrong proofs end it and delete the pending key. Retrying the same registration while
1188
+ a challenge is live resumes it — same row, same expiry, same spent attempts — rather than
1189
+ answering 409.
1190
+
1191
+ **Recovery codes work here**, which is the point of having them: somebody whose authenticator
1192
+ is on the phone they just lost signs in on the replacement with a written-down code.
1193
+
1194
+ ##### Migration
1195
+
1196
+ `LoginResult` gained a required `mfaRequired` and every other field became optional. Narrow on
1197
+ it before reading `userId` — see [the migration note](#migration--narrow-a-sign-in-on-mfarequired-before-reading-userid).
1198
+
1199
+ ##### The web OAuth path
1200
+
1201
+ The backend callback redirects with **`?mfaChallenge=`** instead of `userId` and `keyId`. The
1202
+ value is not a bearer credential for anything but this one `verify`, it is single use, and
1203
+ `requestLogger` records pathnames only — so unlike the sign-out-everywhere link it is not a
1204
+ capability riding a URL.
1205
+
1206
+ Both consumers of that redirect are served:
1207
+
1208
+ - `createOAuthCallbackHandler()` redirects the browser to **`SPFN_AUTH_MFA_CONFIRM_PATH`**
1209
+ (default `/auth/mfa`, or the `mfaPath` option) with `?challenge=` and `?returnUrl=`.
1210
+ - An app on the callback-page flow posts `{ mfaChallenge }` to `POST /_auth/oauth/finalize`,
1211
+ which answers **202** with the challenge echoed back instead of finalizing a session.
1212
+
1213
+ ##### In the Next.js proxy
1214
+
1215
+ Nothing to write. `mfaVerifyInterceptor` — registered for you in `authInterceptors` — seals a
1216
+ `spfn_mfa_pending` cookie on any 202 (the browser's private key, the key id, and the hash of
1217
+ the challenge, for ten minutes) and turns it into the session on a verified `verify`. Its own
1218
+ name and audience, so a social login started in another tab does not overwrite it.
1219
+
1220
+ A session is sealed **only** when the verified response names the same challenge and the same
1221
+ key the cookie holds. Otherwise the proxy answers **401 `SESSION_PENDING_MISMATCH`** without
1222
+ sealing anything, and **401 `SESSION_PENDING_EXPIRED`** when the cookie is gone. The key is
1223
+ active at the backend in both cases — what failed is this browser's claim to be the one that
1224
+ asked — so the remedy is to sign in again.
1225
+
1226
+ ##### From a browser, with the client helpers
1227
+
1228
+ ```typescript
1229
+ import { completeMfaWithCode, completeMfaWithPasskey } from '@spfn/auth/client';
1230
+
1231
+ const result = await authApi.login.call({ body: { email, password } });
1232
+
1233
+ if (result.mfaRequired)
1234
+ {
1235
+ // Keep result.challenge.secret and send the person to your confirm screen.
1236
+ await completeMfaWithCode(authApi, result.challenge.secret, code);
1237
+ // The session cookie is sealed by the time this resolves.
1238
+ }
1239
+ ```
1240
+
1241
+ `completeMfaWithRecoveryCode` takes a written-down code, and `completeMfaWithPasskey` runs the
1242
+ ceremony and answers the same discriminated union the other passkey helpers do.
1243
+
1085
1244
  #### Telling people it exists
1086
1245
 
1087
1246
  `authLoginEvent` and `authDeviceRegisteredEvent` carry **`mfaEnrolled: boolean`**, computed as
@@ -1097,22 +1256,172 @@ enrolment at a first login or when a new device appears. Nothing is ever blocked
1097
1256
  | `StepUpRequiredError` | 403 | `STEP_UP_REQUIRED` | an enrolled account's device is outside the window |
1098
1257
  | `MfaAlreadyEnrolledError` | 409 | — | `totp/enroll` on a confirmed enrolment |
1099
1258
  | `MfaConfigError` | 500 | — | `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` unset, or a stored secret naming a key id no longer in it |
1259
+ | `SessionPendingMismatchError` | 401 | `SESSION_PENDING_MISMATCH` | minted by the proxy: a verified step-up whose challenge or key is not the one this browser's pending cookie holds |
1260
+ | `SessionPendingExpiredError` | 401 | `SESSION_PENDING_EXPIRED` | minted by the proxy: a verified step-up with no pending cookie left to seal a session from |
1100
1261
 
1101
- None of these is a mobile-contract error: the enrolment routes are not contract operations.
1262
+ `MfaVerificationFailedError` is the one contract error here, as the `auth.mfa.*` family of the
1263
+ mobile contract (0.13.0). The enrolment routes are not contract operations, so the rest are
1264
+ not on that surface.
1102
1265
 
1103
1266
  #### The case table
1104
1267
 
1105
- Asserted row by row in `src/__tests__/integration/mfa-enrolment.test.ts` (enrolment) and
1106
- `mfa-step-up.test.ts` (the window); each `it` is named for its row.
1268
+ Asserted row by row in `src/__tests__/integration/mfa-enrolment.test.ts` (enrolment),
1269
+ `mfa-step-up.test.ts` (the window), `mfa-step-up-registration.test.ts` (which channels stop a
1270
+ new device), `mfa-verify.test.ts` (verify × input) and `src/__tests__/unit/mfa-proxy.test.ts`
1271
+ (the Next.js proxy); each `it` is named for its row.
1107
1272
  `mfa-unenrolled-regression.test.ts` pins the status and the body shape an **unenrolled**
1108
1273
  account gets from `login`, `changePassword`, `keys/revoke-all` and `passkeys/revoke`.
1109
1274
 
1110
1275
  #### The sweep
1111
1276
 
1112
- `auth.mfa.sweep` runs daily at 07:00 and deletes enrolments still unconfirmed after 24 hours.
1113
- It is carried by `authJobRouter` beside the other sweeps; pass `mfaSweepCron` to
1277
+ `auth.mfa.sweep` runs daily at 07:00 and deletes enrolments still unconfirmed after 24 hours,
1278
+ plus step-up challenges that have expired or been spent and the inactive keys they were
1279
+ holding. It is carried by `authJobRouter` beside the other sweeps; pass `mfaSweepCron` to
1114
1280
  `createAuthJobRouter()` to move it. A confirmed enrolment is never touched.
1115
1281
 
1282
+ ### Session binding
1283
+
1284
+ A web session's signing key is sealed **inside** the session cookie. That is what makes the
1285
+ cookie a credential rather than a pointer to one — and it means a copy of the cookie *is* that
1286
+ device. A browser profile copied off a laptop, a value pasted out of DevTools, a jar read by
1287
+ malware: the copy signs exactly as the original does, registers no new key, raises no new-device
1288
+ notice, and keeps working until the key is revoked or the session runs out. HttpOnly and
1289
+ `SameSite=Lax` stop page script and cross-site posts; they do nothing about a copy made on the
1290
+ machine.
1291
+
1292
+ Session binding is the opt-in that closes that window. An account that has a platform passkey may
1293
+ turn it on; from then on a web session runs on a key that expires in **hours** instead of ninety
1294
+ days, and only a fresh WebAuthn assertion can put a new one in the cookie. The copy cannot produce
1295
+ the assertion, so it stops working at the first renewal.
1296
+
1297
+ ```typescript
1298
+ // Turn it on. Needs a live passkey and a recently-proved session.
1299
+ await authApi.setSessionBinding.call({ body: { mode: 'passkey' } });
1300
+ // → { mode: 'passkey', keyExpiresAtMillis }
1301
+
1302
+ await authApi.getSessionBinding.call(); // → { mode, keyExpiresAtMillis? }
1303
+
1304
+ // Turn it off. A fresh credential is required — see below.
1305
+ import { disableSessionBinding } from '@spfn/auth/client';
1306
+ await disableSessionBinding(api); // runs the passkey ceremony
1307
+ await disableSessionBinding(api, { currentPassword: '…' }); // or the account password
1308
+ ```
1309
+
1310
+ > **It needs a deployment where the backend can recognise the Next.js proxy.** A key is bound only
1311
+ > on a request `proxy-guard` tagged `clientType: 'web'`, because that is the only signal the
1312
+ > backend has that a request came through the proxy that holds the session cookie — and nothing
1313
+ > else can run the renewal. Without proxy-guard configured, `setSessionBinding` answers 400
1314
+ > `SessionBindingUnavailableError` rather than turning on a switch that would protect nothing.
1315
+
1316
+ **What a copied cookie can and cannot do.** Before the bound key expires, a copy is
1317
+ indistinguishable from the original by anything the server sees — that is the honest statement, and
1318
+ the user-agent family check below is the only thing standing in front of it. After the key
1319
+ expires, the copy has nothing: renewal needs the passkey, and the account's own browser is the one
1320
+ holding it. Turning binding *off* is the privileged direction here, the reverse of the usual
1321
+ posture: `assertRecentAuthentication` is satisfied by the age of the device key a request is signed
1322
+ with, and a cookie copied in the ten minutes after a sign-in carries exactly that — so leaving
1323
+ `'passkey'` mode asks for a passkey assertion or the account password, never key age alone.
1324
+
1325
+ **The renewal page.** Once the key has run out, the backend refuses with `KeyExpiredError`, the
1326
+ proxy turns that into 401 `SessionRenewalRequiredError` and keeps the cookies: the session is
1327
+ waiting on one prompt, not finished. A client component calls `renewSession(api)`, which runs the
1328
+ ceremony and gets a new bound key sealed into the cookie.
1329
+
1330
+ The proxy never refuses on the cookie's own copy of the expiry. `keyExpiresAt` inside the cookie is
1331
+ a hint written at the last seal; the key row is the fact, and only a request that reached the
1332
+ backend can read it. That matters on the second device: turning binding **off** rewrites every
1333
+ active key to an ordinary 90-day one, but only the browser that asked gets a re-sealed cookie, so
1334
+ another device keeps a cookie that says `passkey` with an expiry that no longer applies. Because
1335
+ nothing is decided from that hint, its next request is forwarded, the backend sees an ordinary key
1336
+ and answers 200 — no renewal prompt for a session that does not need one. What that device does
1337
+ keep until it signs in again is its sealed `uaFamily`, so the user-agent family check below goes on
1338
+ applying to it.
1339
+
1340
+ > **Renewal is bound to the expiring key's own signature.** `session/renew/options` and
1341
+ > `session/renew/verify` are not public: they take the ordinary bearer JWT the proxy signs with the
1342
+ > private key in the session cookie, and the key being renewed is that JWT's `keyId` rather than
1343
+ > anything the body says. The one thing they do differently from every other route is admit a key
1344
+ > whose `expiresAt` has passed, while it is bound and inside its grace. So a caller who does not
1345
+ > hold the private half of a key gets the same `SessionRenewalRefusedError` whatever key id they
1346
+ > name — no credential, a wrong signature, an unbound key, a revoked key, one past its grace and an
1347
+ > inactive account are one answer with one body, and whether a key id is live never leaks.
1348
+
1349
+ ```tsx
1350
+ 'use client';
1351
+ import { renewSession } from '@spfn/auth/client';
1352
+ import { authApi } from '@spfn/auth';
1353
+
1354
+ export function RenewSession({ returnTo }: { returnTo: string })
1355
+ {
1356
+ return <button onClick={async () =>
1357
+ {
1358
+ const result = await renewSession(authApi);
1359
+
1360
+ if (result.ok)
1361
+ {
1362
+ location.href = returnTo;
1363
+ }
1364
+ }}>Confirm it's you</button>;
1365
+ }
1366
+ ```
1367
+
1368
+ A server-rendered page cannot run a WebAuthn ceremony, so `RequireAuth` sends it there instead of
1369
+ to the sign-in page:
1370
+
1371
+ ```tsx
1372
+ <RequireAuth renewalPath="/auth/renew">
1373
+ <DashboardContent />
1374
+ </RequireAuth>
1375
+ ```
1376
+
1377
+ `renewalPath` defaults to `SPFN_AUTH_SESSION_RENEW_PATH`, and that to `/auth/renew`.
1378
+ `getAuthSessionData()` answers a third state, `'renewal-required'`, for apps writing their own
1379
+ guard.
1380
+
1381
+ **The user-agent family check.** Independently of expiry, a bound session presented from a
1382
+ different browser family is refused 401 `SessionContextChangedError` and its three cookies are
1383
+ cleared. Browsers do not share cookie jars, so that move cannot happen without a copy. The
1384
+ comparison is coarse on purpose — five families, `edge` / `chrome` / `firefox` / `safari` /
1385
+ `other`, and **no desktop/mobile axis** — so a version bump, a user-agent reduction and Android's
1386
+ "Request desktop site" are all the same browser.
1387
+
1388
+ - **Chrome on iOS and Safari on iOS are different families.** They are different cookie jars, so a
1389
+ session moving between them moved by being copied. An in-app `SFSafariViewController` shares
1390
+ Safari's jar and carries no badge of its own, so it reads as `safari` and passes.
1391
+ - **A request with no `user-agent` is no signal, not a different family.** A server component's
1392
+ `api.` call reaches the proxy as Node `fetch` and carries none; refusing those would refuse every
1393
+ server-rendered page view.
1394
+ - **Unbound accounts are neither checked nor logged.** The check exists for sessions that asked
1395
+ for it.
1396
+
1397
+ **The concurrent-use signal.** `listKeys` rows carry `concurrentUseAtMillis` — the last time one
1398
+ key was seen from two client addresses inside `SPFN_AUTH_CONCURRENT_USE_WINDOW_MS`. It is a signal
1399
+ for a device list to show and notify on, never a refusal: addresses change legitimately, several
1400
+ times an hour for a phone. The addresses behind it are not exposed.
1401
+
1402
+ Only an address `proxy-guard` attested is recorded or compared. Without that attestation
1403
+ `x-forwarded-for` is whatever the caller typed, and a caller who could alternate it on their own key
1404
+ could raise "used from two places at once" whenever they liked; a request with no attested address
1405
+ counts as no observation, which is also why one of them never makes the *next* request look like a
1406
+ move. Where proxy-guard is not configured the signal simply never fires. One key writes at most one
1407
+ address change per window, so a phone flipping between cellular and wifi costs one row update rather
1408
+ than one per request.
1409
+
1410
+ **A binding change that cannot re-seal the cookie fails closed.** Turning binding on or off commits
1411
+ on the backend and then re-seals the session cookie in the proxy's response. If that re-seal cannot
1412
+ happen, the answer is 500 `SessionResealFailedError` with the three session cookies cleared, never
1413
+ the route's 200: a cookie that disagrees with the account is the state the feature exists to avoid,
1414
+ and signing in again is what produces one that agrees.
1415
+
1416
+ **Unbound accounts are unchanged.** Every response, every cookie and every query count is what it
1417
+ was: nothing above applies to an account that did not opt in, and a sign-in that answers without
1418
+ the two binding fields seals exactly the session it always did — which is also what an app calling
1419
+ `saveSession()` by hand gets.
1420
+
1421
+ Contract 0.12.0. `KeySummary.binding`, `KeySummary.concurrentUseAtMillis`,
1422
+ `LoginResponse.sessionBinding` and `LoginResponse.keyExpiresAtMillis` are all optional and absent
1423
+ for an account that did not opt in.
1424
+
1116
1425
  ### Writing protected routes (route DSL)
1117
1426
 
1118
1427
  This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
@@ -1162,6 +1471,11 @@ Client flow: call `authApi.getGoogleOAuthUrl.call({ body: { returnUrl } })`, red
1162
1471
  to the returned `authUrl`, and render `OAuthCallback` on your success page. The Next.js interceptor
1163
1472
  manages the keypair → pending-session-cookie → full-session handoff transparently.
1164
1473
 
1474
+ On an account with a [second factor](#second-factor-mfa) and a device it has not seen, the
1475
+ callback carries `?mfaChallenge=` instead of `userId`/`keyId` and no session is created until
1476
+ that challenge is spent — see [the web OAuth path](#the-web-oauth-path). Both the
1477
+ `createOAuthCallbackHandler` route and the `OAuthCallback` page flow are handled.
1478
+
1165
1479
  ```tsx
1166
1480
  // app/auth/callback/page.tsx
1167
1481
  export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
@@ -1829,6 +2143,13 @@ as the event is emitted. It is the hook an app uses to offer a [second
1829
2143
  factor](#second-factor-mfa) at a first login or when a new device appears; the package itself
1830
2144
  never blocks an account that has none.
1831
2145
 
2146
+ A sign-in that answered **202** because the account needs a [step-up on a new
2147
+ device](#step-up-on-a-new-device) emits neither event, and does not move `lastLoginAt` either.
2148
+ Both are held until `POST /_auth/mfa/verify` succeeds and then fire together, carrying the
2149
+ original channel — so an attacker holding only a password produces no login event and no
2150
+ device notice on an account they never got into, which is exactly the signal the owner needs
2151
+ these events to mean.
2152
+
1832
2153
  Key **rotation** is deliberately not announced — replacing the key of a device that is already
1833
2154
  signed in is not a new device, and a notice for it would teach the owner to ignore the ones that
1834
2155
  matter. A login that names an `oldKeyId` is only a rotation when that key was actually revoked: an
@@ -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' | 'auth.device.start' | 'auth.device.poll' | 'auth.device.info' | 'auth.device.approve' | 'auth.device.deny' | typeof CORE_TIME_OPERATION_ID;
401
+ id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.mfa.verify' | 'auth.mfa.status' | '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
  /**
@@ -481,6 +481,15 @@ declare const CONTRACT_OPERATIONS: readonly ContractOperation[];
481
481
  * `info`, `approve` and `deny` proven, from a device that is already signed in,
482
482
  * which is what lets the server read the approving account from the caller
483
483
  * rather than from the request body.
484
+ *
485
+ * `auth.mfa.verify` is unproven for the same reason the sign-ins are: the key it
486
+ * activates is not usable until it succeeds, so there is nothing to sign the
487
+ * call with. It is the only way to finish a sign-in that answered
488
+ * `mfaRequired: true`, which is why it is a contract operation while the six
489
+ * enrolment routes — all of which need an account screen — are not.
490
+ * `auth.mfa.status` is the one exception among them, because a client that has
491
+ * just met a 202 needs to be able to tell the person what they enrolled. It is
492
+ * a bodyless GET, like `core.time`, so it declares no request type.
484
493
  */
485
494
  declare const AUTH_SURFACE_OPERATIONS: readonly ContractOperation[];
486
495
  /** The body is canonical JSON but not the declared request type. */
@@ -1035,6 +1035,27 @@ var AUTH_SURFACE_OPERATIONS = [
1035
1035
  summary: "Verifies a native/web social id_token server-side and enrolls the client-generated public key.",
1036
1036
  since: "0.3.0"
1037
1037
  },
1038
+ {
1039
+ id: "auth.mfa.verify",
1040
+ method: "POST",
1041
+ path: "/_auth/mfa/verify",
1042
+ authProfile: "none",
1043
+ requiresSession: false,
1044
+ requestType: "MfaVerifyRequest",
1045
+ responseType: "MfaVerifyResponse",
1046
+ summary: "Finishes a sign-in that answered mfaRequired by spending the challenge, which activates the key.",
1047
+ since: "0.13.0"
1048
+ },
1049
+ {
1050
+ id: "auth.mfa.status",
1051
+ method: "GET",
1052
+ path: "/_auth/mfa/status",
1053
+ authProfile: "clientProofV1",
1054
+ requiresSession: false,
1055
+ responseType: "MfaStatusResponse",
1056
+ summary: "Reports whether the caller has a second factor, which methods, and how many recovery codes remain.",
1057
+ since: "0.13.0"
1058
+ },
1038
1059
  {
1039
1060
  id: "auth.keys.rotate",
1040
1061
  method: "POST",
@@ -1380,6 +1401,7 @@ import {
1380
1401
  // src/server/types.ts
1381
1402
  var KEY_ALGORITHM = ["ES256", "RS256"];
1382
1403
  var KEY_PLATFORM = ["ios", "android", "web", "desktop"];
1404
+ var SESSION_BINDINGS = ["none", "passkey"];
1383
1405
 
1384
1406
  // src/server/client-proof/wire-headers.ts
1385
1407
  var CLIENT_IDENTITY_HEADERS = {
@@ -1397,9 +1419,9 @@ function isAppKind(kind) {
1397
1419
  }
1398
1420
 
1399
1421
  // src/server/client-proof/contract-bundle.ts
1400
- var CONTRACT_VERSION = "0.11.0";
1422
+ var CONTRACT_VERSION = "0.13.0";
1401
1423
  var CONTRACT_MAJOR = 0;
1402
- var CONTRACT_SUPPORTED_RANGE = ">=0.11.0 <0.12.0";
1424
+ var CONTRACT_SUPPORTED_RANGE = ">=0.13.0 <0.14.0";
1403
1425
  function required(name, type) {
1404
1426
  return { name, type, optional: false };
1405
1427
  }
@@ -1513,14 +1535,98 @@ var CONTRACT_TYPES = [
1513
1535
  optional("oldKeyId", "string")
1514
1536
  ]
1515
1537
  },
1538
+ /**
1539
+ * The sign-in union, flattened the way the poll union was.
1540
+ *
1541
+ * `mfaRequired` is the discriminant and the only required field; everything
1542
+ * else belongs to one branch. False is a session and carries the five login
1543
+ * fields; true is a 202 carrying `challenge` and nothing else, which the
1544
+ * client spends at `auth.mfa.verify` (#95). This grammar has no union type,
1545
+ * so a required discriminant plus optional fields is the only way to say it
1546
+ * — and the typed web client infers one result type from the same
1547
+ * declaration, so it could not have been a union there either.
1548
+ */
1516
1549
  {
1517
1550
  name: "LoginResponse",
1518
1551
  fields: [
1519
- required("userId", "string"),
1520
- required("publicId", "string"),
1552
+ required("mfaRequired", "boolean"),
1553
+ optional("challenge", "MfaChallenge"),
1554
+ optional("userId", "string"),
1555
+ optional("publicId", "string"),
1556
+ optional("email", "string"),
1557
+ optional("phone", "string"),
1558
+ optional("passwordChangeRequired", "boolean"),
1559
+ optional("sessionBinding", "KeyBinding"),
1560
+ optional("keyExpiresAtMillis", "integer")
1561
+ ]
1562
+ },
1563
+ /**
1564
+ * What a sign-in hands back instead of a session when the account has a
1565
+ * second factor and this device is new to it.
1566
+ *
1567
+ * `secret` is the challenge itself, returned once. The server stores only its
1568
+ * hash and addresses the row by that, so this value is not recoverable from
1569
+ * the database and is not a bearer credential for anything but the one
1570
+ * `auth.mfa.verify` call it belongs to.
1571
+ */
1572
+ {
1573
+ name: "MfaChallenge",
1574
+ fields: [
1575
+ required("secret", "string"),
1576
+ required("expiresAtMillis", "integer")
1577
+ ]
1578
+ },
1579
+ /**
1580
+ * Exactly one of `code` and `recoveryCode`, beside the challenge.
1581
+ *
1582
+ * The third form the server accepts — an assertion from a passkey the owner
1583
+ * marked as a second factor — is not declared: a WebAuthn assertion is a
1584
+ * nested browser object outside this grammar, and the passkey ceremonies are
1585
+ * not on this surface for the same reason.
1586
+ */
1587
+ {
1588
+ name: "MfaVerifyRequest",
1589
+ fields: [
1590
+ required("challenge", "string"),
1591
+ optional("code", "string"),
1592
+ optional("recoveryCode", "string")
1593
+ ]
1594
+ },
1595
+ /**
1596
+ * The sign-in the challenge was standing in for, plus what the proxy needs.
1597
+ *
1598
+ * `mfaRequired` is false on every 200 here — the step-up just happened — and
1599
+ * it is carried so the body is the same `LoginResponse` shape a direct
1600
+ * sign-in answers. `keyId` and `challengeHash` are for the Next.js proxy: it
1601
+ * seals a session only when both match the pending cookie it baked at the
1602
+ * 202, which is what stops a cookie from one flow sealing a session for
1603
+ * another's key.
1604
+ */
1605
+ {
1606
+ name: "MfaVerifyResponse",
1607
+ fields: [
1608
+ required("mfaRequired", "boolean"),
1609
+ required("keyId", "string"),
1610
+ required("challengeHash", "string"),
1611
+ optional("userId", "string"),
1612
+ optional("publicId", "string"),
1521
1613
  optional("email", "string"),
1522
1614
  optional("phone", "string"),
1523
- required("passwordChangeRequired", "boolean")
1615
+ optional("passwordChangeRequired", "boolean"),
1616
+ optional("sessionBinding", "KeyBinding"),
1617
+ optional("keyExpiresAtMillis", "integer")
1618
+ ]
1619
+ },
1620
+ /**
1621
+ * What the account has enrolled. No secret, no otpauth URI, no recovery code
1622
+ * — only the counts and names an account screen renders.
1623
+ */
1624
+ {
1625
+ name: "MfaStatusResponse",
1626
+ fields: [
1627
+ required("enrolled", "boolean"),
1628
+ required("methods", "array<MfaMethod>"),
1629
+ required("recoveryCodesRemaining", "integer")
1524
1630
  ]
1525
1631
  },
1526
1632
  {
@@ -1535,12 +1641,20 @@ var CONTRACT_TYPES = [
1535
1641
  required("algorithm", "KeyAlgorithm")
1536
1642
  ]
1537
1643
  },
1644
+ /**
1645
+ * Flattened on the same terms as `LoginResponse`: a native social sign-in on
1646
+ * an enrolled account and a device it has not seen answers 202 with a
1647
+ * challenge rather than a key, so the discriminant is required and the three
1648
+ * login fields are the false branch.
1649
+ */
1538
1650
  {
1539
1651
  name: "OauthNativeResponse",
1540
1652
  fields: [
1541
- required("userId", "string"),
1542
- required("keyId", "string"),
1543
- required("isNewUser", "boolean")
1653
+ required("mfaRequired", "boolean"),
1654
+ optional("challenge", "MfaChallenge"),
1655
+ optional("userId", "string"),
1656
+ optional("keyId", "string"),
1657
+ optional("isNewUser", "boolean")
1544
1658
  ]
1545
1659
  },
1546
1660
  {
@@ -1580,7 +1694,9 @@ var CONTRACT_TYPES = [
1580
1694
  required("isActive", "boolean"),
1581
1695
  optional("revokedAtMillis", "integer"),
1582
1696
  optional("registeredIp", "string"),
1583
- optional("registeredUserAgent", "string")
1697
+ optional("registeredUserAgent", "string"),
1698
+ optional("binding", "KeyBinding"),
1699
+ optional("concurrentUseAtMillis", "integer")
1584
1700
  ]
1585
1701
  },
1586
1702
  {
@@ -1656,11 +1772,14 @@ var CONTRACT_TYPES = [
1656
1772
  fields: [
1657
1773
  required("status", "DeviceAuthPollStatus"),
1658
1774
  optional("intervalMillis", "integer"),
1775
+ optional("mfaRequired", "boolean"),
1659
1776
  optional("userId", "string"),
1660
1777
  optional("publicId", "string"),
1661
1778
  optional("email", "string"),
1662
1779
  optional("phone", "string"),
1663
- optional("passwordChangeRequired", "boolean")
1780
+ optional("passwordChangeRequired", "boolean"),
1781
+ optional("sessionBinding", "KeyBinding"),
1782
+ optional("keyExpiresAtMillis", "integer")
1664
1783
  ]
1665
1784
  },
1666
1785
  /**
@@ -1701,7 +1820,13 @@ var CONTRACT_TYPES = [
1701
1820
  var CONTRACT_ENUMS = [
1702
1821
  { name: "KeyAlgorithm", values: [...KEY_ALGORITHM] },
1703
1822
  { name: "KeyPlatform", values: [...KEY_PLATFORM] },
1704
- { name: "DeviceAuthPollStatus", values: ["pending", "approved"] }
1823
+ { name: "KeyBinding", values: [...SESSION_BINDINGS] },
1824
+ { name: "DeviceAuthPollStatus", values: ["pending", "approved"] },
1825
+ // The two things an account screen can show as enrolled. `recovery` is a
1826
+ // verification method but never a method the account *has* — a recovery code
1827
+ // is what is left when the authenticator is not to hand, so `mfa/status`
1828
+ // reports it as a count and not as a factor.
1829
+ { name: "MfaMethod", values: ["totp", "passkey"] }
1705
1830
  ];
1706
1831
  var BUNDLE_FILENAME = "spfn-mobile-contract.json";
1707
1832
  var BUNDLE_REPO_PATH = `contracts/mobile/${BUNDLE_FILENAME}`;