@spfn/auth 0.3.0-beta.22 → 0.3.0-beta.24
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 +308 -4
- package/dist/client-proof.js +13 -5
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +52 -1
- package/dist/client.js +40 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +120 -0
- package/dist/config.js +55 -0
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/errors.d.ts +208 -3
- package/dist/errors.js +131 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +71 -5
- package/dist/index.js +134 -3
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-B7N8gux0.d.ts → machine-principals-ZJd9anVT.d.ts} +2045 -886
- package/dist/nextjs/api.js +156 -6
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +42 -24
- package/dist/nextjs/server.js +72 -5
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +1033 -598
- package/dist/server.js +3239 -1541
- 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/20260918162828_handy_titania/migration.sql +35 -0
- package/migrations/20260918162828_handy_titania/snapshot.json +5948 -0
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ real secret values out of band, never commit them.
|
|
|
135
135
|
| `DATABASE_URL` | both | yes | Postgres connection |
|
|
136
136
|
| `SPFN_AUTH_VERIFICATION_TOKEN_SECRET` | `.env.server` | yes | OTP / verification token signing |
|
|
137
137
|
| `SPFN_AUTH_SESSION_SECRET` | `.env.local` | yes | ≥32 chars, AES-256 session cookie encryption (validated: entropy/unique-char checks) |
|
|
138
|
-
| `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` | `.env.server` | web OAuth |
|
|
138
|
+
| `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` | `.env.server` | web OAuth, **MFA** | At-rest keyring: comma-separated `<keyId>:<base64-32-byte-key>` entries; first key is active. Required by any app offering a [second factor](#second-factor-mfa), social login or not |
|
|
139
139
|
| `SPFN_API_URL` | `.env.local` | — | default `http://localhost:8790` |
|
|
140
140
|
| `SPFN_AUTH_SESSION_TTL` | both | — | default `7d` (e.g. `7d`, `12h`, `45m`) |
|
|
141
141
|
| `SPFN_AUTH_JWT_SECRET` / `SPFN_AUTH_JWT_EXPIRES_IN` | `.env.server` | — | legacy server-signed JWT mode only |
|
|
@@ -171,6 +171,12 @@ real secret values out of band, never commit them.
|
|
|
171
171
|
| `SPFN_AUTH_PASSKEY_RP_ID` / `_RP_NAME` / `_ORIGINS` | `.env.server` | — | relying party for passkeys; defaults derive from `{NEXT_PUBLIC_SPFN_APP_URL\|\|SPFN_APP_URL}` and are **checked at boot** — see [Passkeys](#passkeys-webauthn) |
|
|
172
172
|
| `SPFN_AUTH_PASSKEY_USER_VERIFICATION` | `.env.server` | — | `preferred` (default) or `required`; `discouraged` refuses boot |
|
|
173
173
|
| `SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS` / `_RECENT_AUTH_MINUTES` | `.env.server` | — | defaults `300` / `10` — see [Passkeys](#passkeys-webauthn) |
|
|
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
|
+
| `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_BOUND_KEY_TTL_HOURS` | `.env.server` | — | default `24`; how long a passkey-bound session key lives — see [Session binding](#session-binding) |
|
|
177
|
+
| `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
|
+
| `SPFN_AUTH_CONCURRENT_USE_WINDOW_MS` | `.env.server` | — | default `300000`; how close two sightings from two addresses must be to raise `concurrentUseAtMillis` |
|
|
179
|
+
| `SPFN_AUTH_SESSION_RENEW_PATH` | `.env.local` | — | default `/auth/renew`; the page `RequireAuth` sends a bound session whose key ran out |
|
|
174
180
|
| `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
|
|
175
181
|
|
|
176
182
|
Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
|
|
@@ -214,11 +220,24 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
214
220
|
| `listPasskeys` | POST `/_auth/passkeys/list` | yes | the caller's enrolled passkeys |
|
|
215
221
|
| `renamePasskey` | POST `/_auth/passkeys/rename` | yes | rename one |
|
|
216
222
|
| `revokePasskey` | POST `/_auth/passkeys/revoke` | yes | retire one (refused if it is the last way in) |
|
|
223
|
+
| `mfaTotpEnroll` | POST `/_auth/mfa/totp/enroll` | yes | mint a TOTP secret — see [Second factor](#second-factor-mfa) |
|
|
224
|
+
| `mfaTotpConfirm` | POST `/_auth/mfa/totp/confirm` | yes | spend the first code; answers the ten recovery codes |
|
|
225
|
+
| `mfaDisable` | POST `/_auth/mfa/disable` | yes + step-up | remove the second factor (204 either way) |
|
|
226
|
+
| `mfaMarkPasskey` | POST `/_auth/mfa/passkey/mark` | yes + step-up | mark or unmark a passkey as the second factor |
|
|
227
|
+
| `mfaRegenerateRecoveryCodes` | POST `/_auth/mfa/recovery/regenerate` | yes + step-up | ten fresh codes; every earlier one stops verifying |
|
|
228
|
+
| `mfaStatus` | GET `/_auth/mfa/status` | yes | `{ enrolled, methods, recoveryCodesRemaining }`; no secret |
|
|
229
|
+
| `mfaStepUp` | POST `/_auth/mfa/step-up` | yes | re-prove the second factor on this device |
|
|
230
|
+
| `mfaStepUpOptions` | POST `/_auth/mfa/step-up/options` | yes | options for a step-up by passkey |
|
|
217
231
|
| `logout` | POST `/_auth/logout` | yes | revoke current key |
|
|
218
232
|
| `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
|
|
219
233
|
| `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
|
|
220
234
|
| `revokeKey` | POST `/_auth/keys/revoke` | yes | sign one device out |
|
|
221
235
|
| `revokeAllKeys` | POST `/_auth/keys/revoke-all` | yes | sign every device out (spares the caller by default) |
|
|
236
|
+
| `setSessionBinding` | POST `/_auth/session/binding` | yes | turn session binding on or off — see [Session binding](#session-binding) |
|
|
237
|
+
| `getSessionBinding` | GET `/_auth/session/binding` | yes | whether it is on, and when this session's key expires |
|
|
238
|
+
| `sessionBindingDisableOptions` | POST `/_auth/session/binding/disable/options` | yes | the challenge that proves it is you before turning it off |
|
|
239
|
+
| `sessionRenewOptions` | POST `/_auth/session/renew/options` | public | begin renewing a bound session key |
|
|
240
|
+
| `sessionRenewVerify` | POST `/_auth/session/renew/verify` | public | verify the assertion; answers exactly as `login` |
|
|
222
241
|
| `changePassword` | PUT `/_auth/password` | yes | change password |
|
|
223
242
|
| `getAuthSession` | GET `/_auth/session` | yes | current session/user |
|
|
224
243
|
| `issueOneTimeToken` | POST | yes | short-lived token (e.g. SSE handshake) |
|
|
@@ -239,7 +258,10 @@ revisiting that decision.
|
|
|
239
258
|
Auth uses **asymmetric, client-signed JWTs**: the client generates an ES256/RS256 keypair,
|
|
240
259
|
sends the public key on register/login, signs request JWTs locally, and the server verifies
|
|
241
260
|
with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
|
|
242
|
-
Keys expire after 90 days — rotate with `rotateKey
|
|
261
|
+
Keys expire after 90 days — rotate with `rotateKey`, which starts the ninety days again. A key
|
|
262
|
+
bound to a passkey is the one exception: it lives for hours and a rotation carries its expiry over
|
|
263
|
+
rather than resetting it, because only `session/renew` may move that window — see
|
|
264
|
+
[Session binding](#session-binding).
|
|
243
265
|
|
|
244
266
|
### Verified-email signup
|
|
245
267
|
|
|
@@ -523,7 +545,7 @@ cut off anything they no longer recognise.
|
|
|
523
545
|
const { keys } = await authApi.listKeys.call({ body: {} });
|
|
524
546
|
// → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
|
|
525
547
|
// lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis?,
|
|
526
|
-
// registeredIp?, registeredUserAgent? }]
|
|
548
|
+
// registeredIp?, registeredUserAgent?, binding?, concurrentUseAtMillis? }]
|
|
527
549
|
|
|
528
550
|
await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
|
|
529
551
|
```
|
|
@@ -592,6 +614,14 @@ Rotation carries the replaced key's label over unless the client sends a new one
|
|
|
592
614
|
registered before the columns existed; the literal string `unknown` is never stored. They are
|
|
593
615
|
unauthenticated display material, spoofable on any request that does not come through a verified
|
|
594
616
|
proxy, so render them and decide nothing by them. Mobile contract 0.11.0.
|
|
617
|
+
- **`binding` says the key is tied to a passkey**, and is absent on every key that is not — which
|
|
618
|
+
is every key on an account that did not turn [session binding](#session-binding) on. A bound key
|
|
619
|
+
expires in hours and only a passkey assertion renews it.
|
|
620
|
+
- **`concurrentUseAtMillis` is when this key was last seen from two addresses at once**, inside
|
|
621
|
+
`SPFN_AUTH_CONCURRENT_USE_WINDOW_MS`. Absent when that has never been observed, which is the
|
|
622
|
+
ordinary state. A signal to show, never a refusal — addresses change legitimately — and the
|
|
623
|
+
addresses themselves are never returned. Meaningful only where proxy-guard is configured. Mobile
|
|
624
|
+
contract 0.12.0.
|
|
595
625
|
|
|
596
626
|
All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
|
|
597
627
|
`auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
|
|
@@ -978,6 +1008,274 @@ The behaviour above is asserted row by row in
|
|
|
978
1008
|
|
|
979
1009
|
Configuration rows C1–C6 are in `src/__tests__/unit/passkey-config.test.ts`.
|
|
980
1010
|
|
|
1011
|
+
### Second factor (MFA)
|
|
1012
|
+
|
|
1013
|
+
Optional, and optional in the strong sense: an account that never enrols sees exactly the
|
|
1014
|
+
behaviour it saw before this existed, on every route. Nothing here blocks anybody — the
|
|
1015
|
+
package asks for a second factor only from people who asked it to.
|
|
1016
|
+
|
|
1017
|
+
Two forms. A **TOTP** authenticator app (RFC 6238, SHA-1, 30-second steps, six digits, one
|
|
1018
|
+
step of drift), or a **passkey** the owner already enrolled through `/_auth/passkeys/*` and
|
|
1019
|
+
has marked as a second factor. Either one comes with ten single-use recovery codes.
|
|
1020
|
+
|
|
1021
|
+
```
|
|
1022
|
+
enrol → POST /_auth/mfa/totp/enroll → { secret, otpauthUri }, shown once
|
|
1023
|
+
confirm → POST /_auth/mfa/totp/confirm → { recoveryCodes }, ten of them, shown once
|
|
1024
|
+
→ or POST /_auth/mfa/passkey/mark → an existing passkey becomes the second factor
|
|
1025
|
+
inspect → GET /_auth/mfa/status → { enrolled, methods, recoveryCodesRemaining }
|
|
1026
|
+
step up → POST /_auth/mfa/step-up → 204, this device's window reopens
|
|
1027
|
+
remove → POST /_auth/mfa/disable → 204
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
#### Prerequisite: the encryption keyring
|
|
1031
|
+
|
|
1032
|
+
A TOTP secret is encrypted at rest with **`SPFN_AUTH_TOKEN_ENCRYPTION_KEYS`** — the same
|
|
1033
|
+
keyring the OAuth tokens use, in the same `enc:v2:<keyId>:` frame, under its own additional
|
|
1034
|
+
authenticated data so a row cannot be moved between accounts. That variable is listed above
|
|
1035
|
+
as "web OAuth", and it is now also required by any app offering a second factor, **including
|
|
1036
|
+
an app with no social login at all**. `totp/enroll` answers a 500 configuration error while
|
|
1037
|
+
it is unset, and a key id dropped from the keyring answers the same way rather than the 401 a
|
|
1038
|
+
wrong code gets — an operator has to be able to tell a broken deploy from a person misreading
|
|
1039
|
+
their phone. A row written under a key that has since been retired is re-encrypted in place
|
|
1040
|
+
the next time its owner verifies, so a retired key drains as people use their second factor.
|
|
1041
|
+
|
|
1042
|
+
#### Enrolling
|
|
1043
|
+
|
|
1044
|
+
`totp/enroll` mints a 20-byte secret and returns it as RFC 4648 base32 (upper case, no
|
|
1045
|
+
padding) plus the `otpauth://` URI an authenticator app scans. Nothing is enrolled yet:
|
|
1046
|
+
calling it again replaces the pending secret, and a secret nobody confirms is swept away a day
|
|
1047
|
+
later by `auth.mfa.sweep`. `totp/confirm` spends the first code, which is what turns the
|
|
1048
|
+
enrolment into a second factor and issues the recovery codes.
|
|
1049
|
+
|
|
1050
|
+
A submitted code has its spaces and dashes stripped, so `123 456` and `123-456` are the same
|
|
1051
|
+
code. Five wrong codes discard the pending secret — the sixth attempt says there is nothing to
|
|
1052
|
+
confirm, and a fresh `totp/enroll` is the remedy and what resets the counter. A **confirmed**
|
|
1053
|
+
enrolment is never discarded that way; `totp/enroll` on one is a 409, because replacing a
|
|
1054
|
+
working second factor is `disable` followed by a fresh enrolment, both step-up gated.
|
|
1055
|
+
|
|
1056
|
+
The **same code cannot be spent twice**, which is what makes it single-use: the newest step
|
|
1057
|
+
the account has spent is remembered, and a code presented again inside its own thirty seconds
|
|
1058
|
+
is refused. That includes the legitimate case of a second device signing in during the same
|
|
1059
|
+
step — it gets a 401 with the same body as a wrong code, and the client should **retry on the
|
|
1060
|
+
next step** rather than treat it as a bad credential.
|
|
1061
|
+
|
|
1062
|
+
#### Recovery codes
|
|
1063
|
+
|
|
1064
|
+
Ten codes, format `xxxxx-xxxxx`, shown once at confirmation and once at each regeneration.
|
|
1065
|
+
They are stored as **password hashes** rather than as the unsalted SHA-256 the link flows use:
|
|
1066
|
+
a code a human transcribes is short enough that a leaked dump of unsalted hashes would fall to
|
|
1067
|
+
an offline sweep. `recovery/regenerate` raises the generation, so every code from before it
|
|
1068
|
+
stops verifying with the same body as one that never existed. `status` reports how many of the
|
|
1069
|
+
current generation are unspent, which is what an app warns on at two remaining.
|
|
1070
|
+
|
|
1071
|
+
#### The step-up window
|
|
1072
|
+
|
|
1073
|
+
For an **enrolled** account, four kinds of change ask for the second factor again:
|
|
1074
|
+
|
|
1075
|
+
| route | what it changes |
|
|
1076
|
+
|-------|-----------------|
|
|
1077
|
+
| `PUT /_auth/password` | the password, and every other session with it |
|
|
1078
|
+
| `POST /_auth/keys/revoke-all` | every device |
|
|
1079
|
+
| `POST /_auth/mfa/disable`, `recovery/regenerate`, `passkey/mark`, `totp/enroll` | the second factor itself |
|
|
1080
|
+
| `POST /_auth/passkeys/register/options`, `passkeys/revoke` | the account's credentials |
|
|
1081
|
+
|
|
1082
|
+
The rule is per **device key**: this device must have proved the second factor within
|
|
1083
|
+
`SPFN_AUTH_MFA_STEP_UP_MINUTES` (default 10). Otherwise the answer is **403
|
|
1084
|
+
`STEP_UP_REQUIRED`**, and the client sends the user to `POST /_auth/mfa/step-up` — a TOTP
|
|
1085
|
+
code, a recovery code, or an assertion from a marked passkey (options from
|
|
1086
|
+
`POST /_auth/mfa/step-up/options`) — and retries. 403 rather than 401 on purpose, and for the
|
|
1087
|
+
reason `RECENT_AUTH_REQUIRED` is: a 401 on an authenticated route is what a web client reads
|
|
1088
|
+
as "the session is gone", so it would sign the user out instead of asking for a code.
|
|
1089
|
+
|
|
1090
|
+
A key **rotation carries the window across**, because rotating is already proof of the same
|
|
1091
|
+
device — otherwise the web proxy, which rotates at every login, would expire it constantly.
|
|
1092
|
+
|
|
1093
|
+
The two passkey routes keep their own `RECENT_AUTH_REQUIRED` rule unchanged and run it after
|
|
1094
|
+
the step-up: the two guards are independent, and an unenrolled account meets exactly the rule
|
|
1095
|
+
it met before. Unmarking the last second-factor passkey is likewise independent of
|
|
1096
|
+
`LAST_RECOVERY_CREDENTIAL` — removing a mark is not removing a way into the account, so
|
|
1097
|
+
`passkey/mark false` succeeds where `passkeys/revoke` on the same credential is still refused.
|
|
1098
|
+
|
|
1099
|
+
Two sign-ins deliberately produce a session with no verification of its own: a
|
|
1100
|
+
[device-code login](#device-code-login) and a passkey sign-in. Both are exempt at
|
|
1101
|
+
*registration* and both still step up for a sensitive change, which is what
|
|
1102
|
+
`POST /_auth/mfa/step-up` is for. Marking a passkey as a second factor is likewise not proving
|
|
1103
|
+
it, so the device that marks one steps up before it may change the second factor again.
|
|
1104
|
+
|
|
1105
|
+
#### Telling people it exists
|
|
1106
|
+
|
|
1107
|
+
`authLoginEvent` and `authDeviceRegisteredEvent` carry **`mfaEnrolled: boolean`**, computed as
|
|
1108
|
+
the event is emitted. That is the whole of the package's opinion: subscribe and offer
|
|
1109
|
+
enrolment at a first login or when a new device appears. Nothing is ever blocked on it.
|
|
1110
|
+
|
|
1111
|
+
#### Errors
|
|
1112
|
+
|
|
1113
|
+
| error | status | `code` | when |
|
|
1114
|
+
|-------|--------|--------|------|
|
|
1115
|
+
| `MfaVerificationFailedError` | 401 | — | a wrong or stale code, a spent step, a used / old-generation / foreign recovery code, or an assertion from an unmarked passkey |
|
|
1116
|
+
| `MfaNotEnrolledError` | 400 | — | `confirm` with no pending secret (the five-strike deletion included), or `regenerate` on an account with no second factor |
|
|
1117
|
+
| `StepUpRequiredError` | 403 | `STEP_UP_REQUIRED` | an enrolled account's device is outside the window |
|
|
1118
|
+
| `MfaAlreadyEnrolledError` | 409 | — | `totp/enroll` on a confirmed enrolment |
|
|
1119
|
+
| `MfaConfigError` | 500 | — | `SPFN_AUTH_TOKEN_ENCRYPTION_KEYS` unset, or a stored secret naming a key id no longer in it |
|
|
1120
|
+
|
|
1121
|
+
None of these is a mobile-contract error: the enrolment routes are not contract operations.
|
|
1122
|
+
|
|
1123
|
+
#### The case table
|
|
1124
|
+
|
|
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.
|
|
1127
|
+
`mfa-unenrolled-regression.test.ts` pins the status and the body shape an **unenrolled**
|
|
1128
|
+
account gets from `login`, `changePassword`, `keys/revoke-all` and `passkeys/revoke`.
|
|
1129
|
+
|
|
1130
|
+
#### The sweep
|
|
1131
|
+
|
|
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
|
|
1134
|
+
`createAuthJobRouter()` to move it. A confirmed enrolment is never touched.
|
|
1135
|
+
|
|
1136
|
+
### Session binding
|
|
1137
|
+
|
|
1138
|
+
A web session's signing key is sealed **inside** the session cookie. That is what makes the
|
|
1139
|
+
cookie a credential rather than a pointer to one — and it means a copy of the cookie *is* that
|
|
1140
|
+
device. A browser profile copied off a laptop, a value pasted out of DevTools, a jar read by
|
|
1141
|
+
malware: the copy signs exactly as the original does, registers no new key, raises no new-device
|
|
1142
|
+
notice, and keeps working until the key is revoked or the session runs out. HttpOnly and
|
|
1143
|
+
`SameSite=Lax` stop page script and cross-site posts; they do nothing about a copy made on the
|
|
1144
|
+
machine.
|
|
1145
|
+
|
|
1146
|
+
Session binding is the opt-in that closes that window. An account that has a platform passkey may
|
|
1147
|
+
turn it on; from then on a web session runs on a key that expires in **hours** instead of ninety
|
|
1148
|
+
days, and only a fresh WebAuthn assertion can put a new one in the cookie. The copy cannot produce
|
|
1149
|
+
the assertion, so it stops working at the first renewal.
|
|
1150
|
+
|
|
1151
|
+
```typescript
|
|
1152
|
+
// Turn it on. Needs a live passkey and a recently-proved session.
|
|
1153
|
+
await authApi.setSessionBinding.call({ body: { mode: 'passkey' } });
|
|
1154
|
+
// → { mode: 'passkey', keyExpiresAtMillis }
|
|
1155
|
+
|
|
1156
|
+
await authApi.getSessionBinding.call(); // → { mode, keyExpiresAtMillis? }
|
|
1157
|
+
|
|
1158
|
+
// Turn it off. A fresh credential is required — see below.
|
|
1159
|
+
import { disableSessionBinding } from '@spfn/auth/client';
|
|
1160
|
+
await disableSessionBinding(api); // runs the passkey ceremony
|
|
1161
|
+
await disableSessionBinding(api, { currentPassword: '…' }); // or the account password
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
> **It needs a deployment where the backend can recognise the Next.js proxy.** A key is bound only
|
|
1165
|
+
> on a request `proxy-guard` tagged `clientType: 'web'`, because that is the only signal the
|
|
1166
|
+
> backend has that a request came through the proxy that holds the session cookie — and nothing
|
|
1167
|
+
> else can run the renewal. Without proxy-guard configured, `setSessionBinding` answers 400
|
|
1168
|
+
> `SessionBindingUnavailableError` rather than turning on a switch that would protect nothing.
|
|
1169
|
+
|
|
1170
|
+
**What a copied cookie can and cannot do.** Before the bound key expires, a copy is
|
|
1171
|
+
indistinguishable from the original by anything the server sees — that is the honest statement, and
|
|
1172
|
+
the user-agent family check below is the only thing standing in front of it. After the key
|
|
1173
|
+
expires, the copy has nothing: renewal needs the passkey, and the account's own browser is the one
|
|
1174
|
+
holding it. Turning binding *off* is the privileged direction here, the reverse of the usual
|
|
1175
|
+
posture: `assertRecentAuthentication` is satisfied by the age of the device key a request is signed
|
|
1176
|
+
with, and a cookie copied in the ten minutes after a sign-in carries exactly that — so leaving
|
|
1177
|
+
`'passkey'` mode asks for a passkey assertion or the account password, never key age alone.
|
|
1178
|
+
|
|
1179
|
+
**The renewal page.** Once the key has run out, the backend refuses with `KeyExpiredError`, the
|
|
1180
|
+
proxy turns that into 401 `SessionRenewalRequiredError` and keeps the cookies: the session is
|
|
1181
|
+
waiting on one prompt, not finished. A client component calls `renewSession(api)`, which runs the
|
|
1182
|
+
ceremony and gets a new bound key sealed into the cookie.
|
|
1183
|
+
|
|
1184
|
+
The proxy never refuses on the cookie's own copy of the expiry. `keyExpiresAt` inside the cookie is
|
|
1185
|
+
a hint written at the last seal; the key row is the fact, and only a request that reached the
|
|
1186
|
+
backend can read it. That matters on the second device: turning binding **off** rewrites every
|
|
1187
|
+
active key to an ordinary 90-day one, but only the browser that asked gets a re-sealed cookie, so
|
|
1188
|
+
another device keeps a cookie that says `passkey` with an expiry that no longer applies. Because
|
|
1189
|
+
nothing is decided from that hint, its next request is forwarded, the backend sees an ordinary key
|
|
1190
|
+
and answers 200 — no renewal prompt for a session that does not need one. What that device does
|
|
1191
|
+
keep until it signs in again is its sealed `uaFamily`, so the user-agent family check below goes on
|
|
1192
|
+
applying to it.
|
|
1193
|
+
|
|
1194
|
+
> **Renewal is bound to the expiring key's own signature.** `session/renew/options` and
|
|
1195
|
+
> `session/renew/verify` are not public: they take the ordinary bearer JWT the proxy signs with the
|
|
1196
|
+
> private key in the session cookie, and the key being renewed is that JWT's `keyId` rather than
|
|
1197
|
+
> anything the body says. The one thing they do differently from every other route is admit a key
|
|
1198
|
+
> whose `expiresAt` has passed, while it is bound and inside its grace. So a caller who does not
|
|
1199
|
+
> hold the private half of a key gets the same `SessionRenewalRefusedError` whatever key id they
|
|
1200
|
+
> name — no credential, a wrong signature, an unbound key, a revoked key, one past its grace and an
|
|
1201
|
+
> inactive account are one answer with one body, and whether a key id is live never leaks.
|
|
1202
|
+
|
|
1203
|
+
```tsx
|
|
1204
|
+
'use client';
|
|
1205
|
+
import { renewSession } from '@spfn/auth/client';
|
|
1206
|
+
import { authApi } from '@spfn/auth';
|
|
1207
|
+
|
|
1208
|
+
export function RenewSession({ returnTo }: { returnTo: string })
|
|
1209
|
+
{
|
|
1210
|
+
return <button onClick={async () =>
|
|
1211
|
+
{
|
|
1212
|
+
const result = await renewSession(authApi);
|
|
1213
|
+
|
|
1214
|
+
if (result.ok)
|
|
1215
|
+
{
|
|
1216
|
+
location.href = returnTo;
|
|
1217
|
+
}
|
|
1218
|
+
}}>Confirm it's you</button>;
|
|
1219
|
+
}
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
A server-rendered page cannot run a WebAuthn ceremony, so `RequireAuth` sends it there instead of
|
|
1223
|
+
to the sign-in page:
|
|
1224
|
+
|
|
1225
|
+
```tsx
|
|
1226
|
+
<RequireAuth renewalPath="/auth/renew">
|
|
1227
|
+
<DashboardContent />
|
|
1228
|
+
</RequireAuth>
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
`renewalPath` defaults to `SPFN_AUTH_SESSION_RENEW_PATH`, and that to `/auth/renew`.
|
|
1232
|
+
`getAuthSessionData()` answers a third state, `'renewal-required'`, for apps writing their own
|
|
1233
|
+
guard.
|
|
1234
|
+
|
|
1235
|
+
**The user-agent family check.** Independently of expiry, a bound session presented from a
|
|
1236
|
+
different browser family is refused 401 `SessionContextChangedError` and its three cookies are
|
|
1237
|
+
cleared. Browsers do not share cookie jars, so that move cannot happen without a copy. The
|
|
1238
|
+
comparison is coarse on purpose — five families, `edge` / `chrome` / `firefox` / `safari` /
|
|
1239
|
+
`other`, and **no desktop/mobile axis** — so a version bump, a user-agent reduction and Android's
|
|
1240
|
+
"Request desktop site" are all the same browser.
|
|
1241
|
+
|
|
1242
|
+
- **Chrome on iOS and Safari on iOS are different families.** They are different cookie jars, so a
|
|
1243
|
+
session moving between them moved by being copied. An in-app `SFSafariViewController` shares
|
|
1244
|
+
Safari's jar and carries no badge of its own, so it reads as `safari` and passes.
|
|
1245
|
+
- **A request with no `user-agent` is no signal, not a different family.** A server component's
|
|
1246
|
+
`api.` call reaches the proxy as Node `fetch` and carries none; refusing those would refuse every
|
|
1247
|
+
server-rendered page view.
|
|
1248
|
+
- **Unbound accounts are neither checked nor logged.** The check exists for sessions that asked
|
|
1249
|
+
for it.
|
|
1250
|
+
|
|
1251
|
+
**The concurrent-use signal.** `listKeys` rows carry `concurrentUseAtMillis` — the last time one
|
|
1252
|
+
key was seen from two client addresses inside `SPFN_AUTH_CONCURRENT_USE_WINDOW_MS`. It is a signal
|
|
1253
|
+
for a device list to show and notify on, never a refusal: addresses change legitimately, several
|
|
1254
|
+
times an hour for a phone. The addresses behind it are not exposed.
|
|
1255
|
+
|
|
1256
|
+
Only an address `proxy-guard` attested is recorded or compared. Without that attestation
|
|
1257
|
+
`x-forwarded-for` is whatever the caller typed, and a caller who could alternate it on their own key
|
|
1258
|
+
could raise "used from two places at once" whenever they liked; a request with no attested address
|
|
1259
|
+
counts as no observation, which is also why one of them never makes the *next* request look like a
|
|
1260
|
+
move. Where proxy-guard is not configured the signal simply never fires. One key writes at most one
|
|
1261
|
+
address change per window, so a phone flipping between cellular and wifi costs one row update rather
|
|
1262
|
+
than one per request.
|
|
1263
|
+
|
|
1264
|
+
**A binding change that cannot re-seal the cookie fails closed.** Turning binding on or off commits
|
|
1265
|
+
on the backend and then re-seals the session cookie in the proxy's response. If that re-seal cannot
|
|
1266
|
+
happen, the answer is 500 `SessionResealFailedError` with the three session cookies cleared, never
|
|
1267
|
+
the route's 200: a cookie that disagrees with the account is the state the feature exists to avoid,
|
|
1268
|
+
and signing in again is what produces one that agrees.
|
|
1269
|
+
|
|
1270
|
+
**Unbound accounts are unchanged.** Every response, every cookie and every query count is what it
|
|
1271
|
+
was: nothing above applies to an account that did not opt in, and a sign-in that answers without
|
|
1272
|
+
the two binding fields seals exactly the session it always did — which is also what an app calling
|
|
1273
|
+
`saveSession()` by hand gets.
|
|
1274
|
+
|
|
1275
|
+
Contract 0.12.0. `KeySummary.binding`, `KeySummary.concurrentUseAtMillis`,
|
|
1276
|
+
`LoginResponse.sessionBinding` and `LoginResponse.keyExpiresAtMillis` are all optional and absent
|
|
1277
|
+
for an account that did not opt in.
|
|
1278
|
+
|
|
981
1279
|
### Writing protected routes (route DSL)
|
|
982
1280
|
|
|
983
1281
|
This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
|
|
@@ -1689,6 +1987,11 @@ and not what it began on, so a stolen password used on a new machine was silent
|
|
|
1689
1987
|
Send it with a [sign-out-everywhere link](#the-sign-out-everywhere-link), which is the action the
|
|
1690
1988
|
notice should offer.
|
|
1691
1989
|
|
|
1990
|
+
Both `authLoginEvent` and `authDeviceRegisteredEvent` carry `mfaEnrolled: boolean`, computed
|
|
1991
|
+
as the event is emitted. It is the hook an app uses to offer a [second
|
|
1992
|
+
factor](#second-factor-mfa) at a first login or when a new device appears; the package itself
|
|
1993
|
+
never blocks an account that has none.
|
|
1994
|
+
|
|
1692
1995
|
Key **rotation** is deliberately not announced — replacing the key of a device that is already
|
|
1693
1996
|
signed in is not a new device, and a notice for it would teach the owner to ignore the ones that
|
|
1694
1997
|
matter. A login that names an `oldKeyId` is only a rotation when that key was actually revoked: an
|
|
@@ -1700,7 +2003,8 @@ Payload types: `AuthLoginPayload`, `AuthRegisterPayload`, `AuthPasswordResetPayl
|
|
|
1700
2003
|
`InvitationAcceptedPayload`, `AuthDeletionRequestedPayload`, `AuthDeletionCancelledPayload`,
|
|
1701
2004
|
`AuthDeletionCompletedPayload`, `OAuthUnlinkedPayload` (`auth.oauth.unlinked` — provider-side
|
|
1702
2005
|
disconnect, see the OAuth unlink-notify section), `PasskeyEnrolledPayload`,
|
|
1703
|
-
`PasskeyRevokedPayload`.
|
|
2006
|
+
`PasskeyRevokedPayload`. `AuthLoginPayload` and `AuthDeviceRegisteredPayload` both gained
|
|
2007
|
+
`mfaEnrolled` in 0.3.0-beta.23. These events also bind to `@spfn/core/job`
|
|
1704
2008
|
jobs via `.on(event)`.
|
|
1705
2009
|
|
|
1706
2010
|
## Registration gate (`beforeRegister`)
|
package/dist/client-proof.js
CHANGED
|
@@ -1380,6 +1380,7 @@ import {
|
|
|
1380
1380
|
// src/server/types.ts
|
|
1381
1381
|
var KEY_ALGORITHM = ["ES256", "RS256"];
|
|
1382
1382
|
var KEY_PLATFORM = ["ios", "android", "web", "desktop"];
|
|
1383
|
+
var SESSION_BINDINGS = ["none", "passkey"];
|
|
1383
1384
|
|
|
1384
1385
|
// src/server/client-proof/wire-headers.ts
|
|
1385
1386
|
var CLIENT_IDENTITY_HEADERS = {
|
|
@@ -1397,9 +1398,9 @@ function isAppKind(kind) {
|
|
|
1397
1398
|
}
|
|
1398
1399
|
|
|
1399
1400
|
// src/server/client-proof/contract-bundle.ts
|
|
1400
|
-
var CONTRACT_VERSION = "0.
|
|
1401
|
+
var CONTRACT_VERSION = "0.12.0";
|
|
1401
1402
|
var CONTRACT_MAJOR = 0;
|
|
1402
|
-
var CONTRACT_SUPPORTED_RANGE = ">=0.
|
|
1403
|
+
var CONTRACT_SUPPORTED_RANGE = ">=0.12.0 <0.13.0";
|
|
1403
1404
|
function required(name, type) {
|
|
1404
1405
|
return { name, type, optional: false };
|
|
1405
1406
|
}
|
|
@@ -1520,7 +1521,9 @@ var CONTRACT_TYPES = [
|
|
|
1520
1521
|
required("publicId", "string"),
|
|
1521
1522
|
optional("email", "string"),
|
|
1522
1523
|
optional("phone", "string"),
|
|
1523
|
-
required("passwordChangeRequired", "boolean")
|
|
1524
|
+
required("passwordChangeRequired", "boolean"),
|
|
1525
|
+
optional("sessionBinding", "KeyBinding"),
|
|
1526
|
+
optional("keyExpiresAtMillis", "integer")
|
|
1524
1527
|
]
|
|
1525
1528
|
},
|
|
1526
1529
|
{
|
|
@@ -1580,7 +1583,9 @@ var CONTRACT_TYPES = [
|
|
|
1580
1583
|
required("isActive", "boolean"),
|
|
1581
1584
|
optional("revokedAtMillis", "integer"),
|
|
1582
1585
|
optional("registeredIp", "string"),
|
|
1583
|
-
optional("registeredUserAgent", "string")
|
|
1586
|
+
optional("registeredUserAgent", "string"),
|
|
1587
|
+
optional("binding", "KeyBinding"),
|
|
1588
|
+
optional("concurrentUseAtMillis", "integer")
|
|
1584
1589
|
]
|
|
1585
1590
|
},
|
|
1586
1591
|
{
|
|
@@ -1660,7 +1665,9 @@ var CONTRACT_TYPES = [
|
|
|
1660
1665
|
optional("publicId", "string"),
|
|
1661
1666
|
optional("email", "string"),
|
|
1662
1667
|
optional("phone", "string"),
|
|
1663
|
-
optional("passwordChangeRequired", "boolean")
|
|
1668
|
+
optional("passwordChangeRequired", "boolean"),
|
|
1669
|
+
optional("sessionBinding", "KeyBinding"),
|
|
1670
|
+
optional("keyExpiresAtMillis", "integer")
|
|
1664
1671
|
]
|
|
1665
1672
|
},
|
|
1666
1673
|
/**
|
|
@@ -1701,6 +1708,7 @@ var CONTRACT_TYPES = [
|
|
|
1701
1708
|
var CONTRACT_ENUMS = [
|
|
1702
1709
|
{ name: "KeyAlgorithm", values: [...KEY_ALGORITHM] },
|
|
1703
1710
|
{ name: "KeyPlatform", values: [...KEY_PLATFORM] },
|
|
1711
|
+
{ name: "KeyBinding", values: [...SESSION_BINDINGS] },
|
|
1704
1712
|
{ name: "DeviceAuthPollStatus", values: ["pending", "approved"] }
|
|
1705
1713
|
];
|
|
1706
1714
|
var BUNDLE_FILENAME = "spfn-mobile-contract.json";
|