@spfn/auth 0.3.0-beta.23 → 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 CHANGED
@@ -173,6 +173,10 @@ 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_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 |
176
180
  | `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
177
181
 
178
182
  Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
@@ -229,6 +233,11 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
229
233
  | `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
230
234
  | `revokeKey` | POST `/_auth/keys/revoke` | yes | sign one device out |
231
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` |
232
241
  | `changePassword` | PUT `/_auth/password` | yes | change password |
233
242
  | `getAuthSession` | GET `/_auth/session` | yes | current session/user |
234
243
  | `issueOneTimeToken` | POST | yes | short-lived token (e.g. SSE handshake) |
@@ -249,7 +258,10 @@ revisiting that decision.
249
258
  Auth uses **asymmetric, client-signed JWTs**: the client generates an ES256/RS256 keypair,
250
259
  sends the public key on register/login, signs request JWTs locally, and the server verifies
251
260
  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`.
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).
253
265
 
254
266
  ### Verified-email signup
255
267
 
@@ -533,7 +545,7 @@ cut off anything they no longer recognise.
533
545
  const { keys } = await authApi.listKeys.call({ body: {} });
534
546
  // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
535
547
  // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis?,
536
- // registeredIp?, registeredUserAgent? }]
548
+ // registeredIp?, registeredUserAgent?, binding?, concurrentUseAtMillis? }]
537
549
 
538
550
  await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
539
551
  ```
@@ -602,6 +614,14 @@ Rotation carries the replaced key's label over unless the client sends a new one
602
614
  registered before the columns existed; the literal string `unknown` is never stored. They are
603
615
  unauthenticated display material, spoofable on any request that does not come through a verified
604
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.
605
625
 
606
626
  All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
607
627
  `auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
@@ -1113,6 +1133,149 @@ account gets from `login`, `changePassword`, `keys/revoke-all` and `passkeys/rev
1113
1133
  It is carried by `authJobRouter` beside the other sweeps; pass `mfaSweepCron` to
1114
1134
  `createAuthJobRouter()` to move it. A confirmed enrolment is never touched.
1115
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
+
1116
1279
  ### Writing protected routes (route DSL)
1117
1280
 
1118
1281
  This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
@@ -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.11.0";
1401
+ var CONTRACT_VERSION = "0.12.0";
1401
1402
  var CONTRACT_MAJOR = 0;
1402
- var CONTRACT_SUPPORTED_RANGE = ">=0.11.0 <0.12.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";