@spfn/auth 0.3.0-beta.24 → 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 CHANGED
@@ -173,6 +173,8 @@ 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 |
176
178
  | `SPFN_AUTH_BOUND_KEY_TTL_HOURS` | `.env.server` | — | default `24`; how long a passkey-bound session key lives — see [Session binding](#session-binding) |
177
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 |
178
180
  | `SPFN_AUTH_CONCURRENT_USE_WINDOW_MS` | `.env.server` | — | default `300000`; how close two sightings from two addresses must be to raise `concurrentUseAtMillis` |
@@ -228,6 +230,8 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
228
230
  | `mfaStatus` | GET `/_auth/mfa/status` | yes | `{ enrolled, methods, recoveryCodesRemaining }`; no secret |
229
231
  | `mfaStepUp` | POST `/_auth/mfa/step-up` | yes | re-prove the second factor on this device |
230
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 |
231
235
  | `logout` | POST `/_auth/logout` | yes | revoke current key |
232
236
  | `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
233
237
  | `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
@@ -263,6 +267,44 @@ bound to a passkey is the one exception: it lives for hours and a rotation carri
263
267
  rather than resetting it, because only `session/renew` may move that window — see
264
268
  [Session binding](#session-binding).
265
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.
307
+
266
308
  ### Verified-email signup
267
309
 
268
310
  A second way in, alongside the six-digit code. The address is proven before a password
@@ -541,6 +583,10 @@ Keys are per-device, so a login never revokes the previous key and they accumula
541
583
  `listKeys` / `revokeKey` / `revokeAllKeys` are what let the account owner see what accumulated and
542
584
  cut off anything they no longer recognise.
543
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
+
544
590
  ```typescript
545
591
  const { keys } = await authApi.listKeys.call({ body: {} });
546
592
  // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
@@ -1102,6 +1148,99 @@ Two sign-ins deliberately produce a session with no verification of its own: a
1102
1148
  `POST /_auth/mfa/step-up` is for. Marking a passkey as a second factor is likewise not proving
1103
1149
  it, so the device that marks one steps up before it may change the second factor again.
1104
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
+
1105
1244
  #### Telling people it exists
1106
1245
 
1107
1246
  `authLoginEvent` and `authDeviceRegisteredEvent` carry **`mfaEnrolled: boolean`**, computed as
@@ -1117,20 +1256,27 @@ enrolment at a first login or when a new device appears. Nothing is ever blocked
1117
1256
  | `StepUpRequiredError` | 403 | `STEP_UP_REQUIRED` | an enrolled account's device is outside the window |
1118
1257
  | `MfaAlreadyEnrolledError` | 409 | — | `totp/enroll` on a confirmed enrolment |
1119
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 |
1120
1261
 
1121
- 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.
1122
1265
 
1123
1266
  #### The case table
1124
1267
 
1125
- Asserted row by row in `src/__tests__/integration/mfa-enrolment.test.ts` (enrolment) and
1126
- `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.
1127
1272
  `mfa-unenrolled-regression.test.ts` pins the status and the body shape an **unenrolled**
1128
1273
  account gets from `login`, `changePassword`, `keys/revoke-all` and `passkeys/revoke`.
1129
1274
 
1130
1275
  #### The sweep
1131
1276
 
1132
- `auth.mfa.sweep` runs daily at 07:00 and deletes enrolments still unconfirmed after 24 hours.
1133
- 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
1134
1280
  `createAuthJobRouter()` to move it. A confirmed enrolment is never touched.
1135
1281
 
1136
1282
  ### Session binding
@@ -1325,6 +1471,11 @@ Client flow: call `authApi.getGoogleOAuthUrl.call({ body: { returnUrl } })`, red
1325
1471
  to the returned `authUrl`, and render `OAuthCallback` on your success page. The Next.js interceptor
1326
1472
  manages the keypair → pending-session-cookie → full-session handoff transparently.
1327
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
+
1328
1479
  ```tsx
1329
1480
  // app/auth/callback/page.tsx
1330
1481
  export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
@@ -1992,6 +2143,13 @@ as the event is emitted. It is the hook an app uses to offer a [second
1992
2143
  factor](#second-factor-mfa) at a first login or when a new device appears; the package itself
1993
2144
  never blocks an account that has none.
1994
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
+
1995
2153
  Key **rotation** is deliberately not announced — replacing the key of a device that is already
1996
2154
  signed in is not a new device, and a notice for it would teach the owner to ignore the ones that
1997
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",
@@ -1398,9 +1419,9 @@ function isAppKind(kind) {
1398
1419
  }
1399
1420
 
1400
1421
  // src/server/client-proof/contract-bundle.ts
1401
- var CONTRACT_VERSION = "0.12.0";
1422
+ var CONTRACT_VERSION = "0.13.0";
1402
1423
  var CONTRACT_MAJOR = 0;
1403
- var CONTRACT_SUPPORTED_RANGE = ">=0.12.0 <0.13.0";
1424
+ var CONTRACT_SUPPORTED_RANGE = ">=0.13.0 <0.14.0";
1404
1425
  function required(name, type) {
1405
1426
  return { name, type, optional: false };
1406
1427
  }
@@ -1514,18 +1535,100 @@ var CONTRACT_TYPES = [
1514
1535
  optional("oldKeyId", "string")
1515
1536
  ]
1516
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
+ */
1517
1549
  {
1518
1550
  name: "LoginResponse",
1519
1551
  fields: [
1520
- required("userId", "string"),
1521
- 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"),
1522
1613
  optional("email", "string"),
1523
1614
  optional("phone", "string"),
1524
- required("passwordChangeRequired", "boolean"),
1615
+ optional("passwordChangeRequired", "boolean"),
1525
1616
  optional("sessionBinding", "KeyBinding"),
1526
1617
  optional("keyExpiresAtMillis", "integer")
1527
1618
  ]
1528
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")
1630
+ ]
1631
+ },
1529
1632
  {
1530
1633
  name: "OauthNativeRequest",
1531
1634
  fields: [
@@ -1538,12 +1641,20 @@ var CONTRACT_TYPES = [
1538
1641
  required("algorithm", "KeyAlgorithm")
1539
1642
  ]
1540
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
+ */
1541
1650
  {
1542
1651
  name: "OauthNativeResponse",
1543
1652
  fields: [
1544
- required("userId", "string"),
1545
- required("keyId", "string"),
1546
- required("isNewUser", "boolean")
1653
+ required("mfaRequired", "boolean"),
1654
+ optional("challenge", "MfaChallenge"),
1655
+ optional("userId", "string"),
1656
+ optional("keyId", "string"),
1657
+ optional("isNewUser", "boolean")
1547
1658
  ]
1548
1659
  },
1549
1660
  {
@@ -1661,6 +1772,7 @@ var CONTRACT_TYPES = [
1661
1772
  fields: [
1662
1773
  required("status", "DeviceAuthPollStatus"),
1663
1774
  optional("intervalMillis", "integer"),
1775
+ optional("mfaRequired", "boolean"),
1664
1776
  optional("userId", "string"),
1665
1777
  optional("publicId", "string"),
1666
1778
  optional("email", "string"),
@@ -1709,7 +1821,12 @@ var CONTRACT_ENUMS = [
1709
1821
  { name: "KeyAlgorithm", values: [...KEY_ALGORITHM] },
1710
1822
  { name: "KeyPlatform", values: [...KEY_PLATFORM] },
1711
1823
  { name: "KeyBinding", values: [...SESSION_BINDINGS] },
1712
- { name: "DeviceAuthPollStatus", values: ["pending", "approved"] }
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"] }
1713
1830
  ];
1714
1831
  var BUNDLE_FILENAME = "spfn-mobile-contract.json";
1715
1832
  var BUNDLE_REPO_PATH = `contracts/mobile/${BUNDLE_FILENAME}`;