@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.
- package/README.md +328 -7
- package/dist/client-proof.d.ts +10 -1
- package/dist/client-proof.js +136 -11
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +101 -1
- package/dist/client.js +65 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +122 -0
- package/dist/config.js +53 -0
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/errors.d.ts +159 -3
- package/dist/errors.js +95 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +52 -12
- package/dist/index.js +104 -3
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-CaEFq61K.d.ts → machine-principals-CdEgxOB1.d.ts} +2049 -771
- package/dist/nextjs/api.js +329 -37
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +59 -24
- package/dist/nextjs/server.js +105 -11
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +415 -332
- package/dist/server.js +2933 -1545
- package/dist/server.js.map +1 -1
- package/dist/{session-Dfwu5g2W.d.ts → session-BbhAGZtA.d.ts} +57 -1
- package/dist/{types-DYyhze28.d.ts → types-CTdoTOxM.d.ts} +24 -1
- package/migrations/20260918184037_happy_mordo/migration.sql +4 -0
- package/migrations/20260918184037_happy_mordo/snapshot.json +6000 -0
- package/migrations/20260918184152_dear_rictor/migration.sql +3 -0
- package/migrations/20260918184152_dear_rictor/snapshot.json +6039 -0
- package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
- package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
- 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
|
-
|
|
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)
|
|
1106
|
-
`mfa-step-up.test.ts` (the window)
|
|
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
|
-
|
|
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
|
package/dist/client-proof.d.ts
CHANGED
|
@@ -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. */
|
package/dist/client-proof.js
CHANGED
|
@@ -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.
|
|
1422
|
+
var CONTRACT_VERSION = "0.13.0";
|
|
1401
1423
|
var CONTRACT_MAJOR = 0;
|
|
1402
|
-
var CONTRACT_SUPPORTED_RANGE = ">=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("
|
|
1520
|
-
|
|
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
|
-
|
|
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("
|
|
1542
|
-
|
|
1543
|
-
|
|
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: "
|
|
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}`;
|