@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 +163 -5
- package/dist/client-proof.d.ts +10 -1
- package/dist/client-proof.js +126 -9
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +50 -1
- package/dist/client.js +25 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +48 -2
- package/dist/errors.js +27 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +23 -16
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-ZJd9anVT.d.ts → machine-principals-CdEgxOB1.d.ts} +1397 -886
- package/dist/nextjs/api.js +182 -40
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +17 -0
- package/dist/nextjs/server.js +33 -6
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +256 -96
- package/dist/server.js +1328 -676
- package/dist/server.js.map +1 -1
- 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,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
|
-
|
|
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)
|
|
1126
|
-
`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.
|
|
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
|
-
|
|
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
|
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",
|
|
@@ -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.
|
|
1422
|
+
var CONTRACT_VERSION = "0.13.0";
|
|
1402
1423
|
var CONTRACT_MAJOR = 0;
|
|
1403
|
-
var CONTRACT_SUPPORTED_RANGE = ">=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("
|
|
1521
|
-
|
|
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
|
-
|
|
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("
|
|
1545
|
-
|
|
1546
|
-
|
|
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}`;
|