@spfn/auth 0.3.0-beta.27 → 0.3.0-beta.28

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
@@ -219,6 +219,13 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
219
219
  | `getDeviceAuthInfo` | POST `/_auth/device/info` | yes | what device is asking, so the approval screen can show it |
220
220
  | `approveDeviceAuth` | POST `/_auth/device/approve` | yes | let the waiting device in |
221
221
  | `denyDeviceAuth` | POST `/_auth/device/deny` | yes | refuse it |
222
+ | `issueDeviceLink` | POST `/_auth/device/link/issue` | yes | show a code a new device can come in by — see [Device link](#device-link) |
223
+ | `redeemDeviceLink` | POST `/_auth/device/link/redeem` | public | the new device parks its key on that code and gets the match number to show |
224
+ | `getDeviceLinkStatus` | POST `/_auth/device/link/status` | issuing key | where the link stands; the device and three numbers once redeemed |
225
+ | `confirmDeviceLink` | POST `/_auth/device/link/confirm` | issuing key | pick the number the new device shows |
226
+ | `denyDeviceLink` | POST `/_auth/device/link/deny` | issuing key | refuse the new device |
227
+ | `cancelDeviceLink` | POST `/_auth/device/link/cancel` | issuing key | close the link before anyone is let in |
228
+ | `pollDeviceLink` | POST `/_auth/device/link/poll` | public | ask whether the issuer picked; the approved answer *is* the login |
222
229
  | `passkeyRegisterOptions` | POST `/_auth/passkeys/register/options` | yes | begin enrolling a passkey — see [Passkeys](#passkeys-webauthn) |
223
230
  | `passkeyRegisterVerify` | POST `/_auth/passkeys/register/verify` | yes | verify the attestation and keep the credential |
224
231
  | `passkeyLoginOptions` | POST `/_auth/passkeys/login/options` | public | begin a passkey sign-in; takes no identifier |
@@ -616,6 +623,141 @@ No job sweeps the table. Rows are judged by `expiresAt` whenever they are read o
616
623
  stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
617
624
  not run out.
618
625
 
626
+ ### Device link
627
+
628
+ Device-code login the other way round: the device that is already signed in shows the code,
629
+ and the new one reads it. This is the natural way onto a phone — the signed-in device is a
630
+ laptop with a screen, and the phone has a camera. The signed-in device (the *issuer*) asks for a
631
+ code and shows it, as text and as a QR its own client draws. The new device reads it, sends it
632
+ with its fresh public key, and shows a two-digit number. The issuer is shown the new device and
633
+ three numbers, and taps the one on the new device's screen. The new device's next poll is its
634
+ login.
635
+
636
+ ```typescript
637
+ // On the signed-in device — the issuer. Every call below is signed with its key.
638
+ const { linkId, userCode, expiresAtMillis } = await authApi.issueDeviceLink.call({});
639
+
640
+ // Show `userCode` (XXXX-XXXX) as text and in a QR code, then wait for a device to use it.
641
+ let link;
642
+
643
+ do
644
+ {
645
+ // Held up to 20s while nobody has redeemed the code (deviceAuth.maxWaitMs caps it).
646
+ link = await authApi.getDeviceLinkStatus.call({ body: { linkId, waitMillis: 20_000 } });
647
+ }
648
+ while (link.status === 'issued');
649
+ // → { status: 'redeemed', deviceName?, platform?, fingerprintPrefix, redeemedAtMillis,
650
+ // choices: [37, 82, 15], expiresAtMillis }
651
+
652
+ // Show the device and the three numbers; the person taps the one on the new device.
653
+ await authApi.confirmDeviceLink.call({ body: { linkId, choice: tapped } });
654
+ // or authApi.denyDeviceLink.call({ body: { linkId } }); closing the screen:
655
+ // authApi.cancelDeviceLink.call({ body: { linkId } })
656
+ ```
657
+
658
+ ```typescript
659
+ // On the new device — it has no key on file, so both calls are public.
660
+ const { deviceCode, matchNumber, expiresAtMillis, intervalMillis } =
661
+ await authApi.redeemDeviceLink.call({ body: {
662
+ userCode, // read from the QR, or typed
663
+ publicKey, keyId, fingerprint, algorithm: 'ES256',
664
+ deviceName: 'Pocket phone', platform: 'ios',
665
+ } });
666
+
667
+ // Show `matchNumber` large ("tap 37 on your computer"), then long-poll as device-code does.
668
+ let answer;
669
+
670
+ do
671
+ {
672
+ answer = await authApi.pollDeviceLink.call({ body: { deviceCode, waitMillis: 20_000 } });
673
+
674
+ if (answer.status === 'pending' && answer.intervalMillis > 0)
675
+ {
676
+ await new Promise(resolve => setTimeout(resolve, answer.intervalMillis));
677
+ }
678
+ }
679
+ while (answer.status === 'pending');
680
+ // → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }
681
+ ```
682
+
683
+ The approved answer is the one `pollDeviceAuth` and `login` return, produced by the same
684
+ completion — account status checks, key registration, the login event — so a client cannot tell
685
+ which way in it took. Its key is registered under the issuer's account with channel
686
+ `device-link` on `auth.device.registered`.
687
+
688
+ | state ↓ call → | redeem | status | confirm | deny | cancel | poll |
689
+ | --- | --- | --- | --- | --- | --- | --- |
690
+ | issued | → redeemed | `issued` | 409 NotRedeemed | 409 NotRedeemed | → expired | — |
691
+ | redeemed | 404 | device + `choices` | right number → approved; wrong → denied + 400 WrongMatch | → denied | → expired | `pending` |
692
+ | approved | 404 | `approved` | 409 AlreadyHandled | 409 AlreadyHandled | 409 AlreadyHandled | login, → consumed |
693
+ | denied | 404 | `denied` | 409 AlreadyHandled | 409 AlreadyHandled | 409 AlreadyHandled | 403 Denied |
694
+ | consumed | 404 | `consumed` | 409 AlreadyHandled | 409 AlreadyHandled | 409 AlreadyHandled | 404 |
695
+ | dead (TTL, cancelled, replaced, issuer signed out) | 400 Expired | 400 Expired | 400 Expired | 400 Expired | 400 Expired | 400 Expired |
696
+ | unknown, or another key's link | 404 | 404 | 404 | 404 | 404 | 404 |
697
+
698
+ Error names are `DeviceLink` + the cell: `DeviceLinkNotFoundError` (404),
699
+ `DeviceLinkExpiredError` (400), `DeviceLinkWrongMatchError` (400), `DeviceLinkDeniedError` (403),
700
+ `DeviceLinkNotRedeemedError` (409), `DeviceLinkAlreadyHandledError` (409). A consumed link stays
701
+ 404 to the new device after its TTL, for device-code login's reason.
702
+
703
+ - **Single use.** A code is redeemed once, by one device: redeem moves the link from `issued` and
704
+ nowhere else, so of two devices sending the same code exactly one parks its key, and the other
705
+ is told the code does not exist. The approval is collected once, the same way: of two polls
706
+ arriving together, one registers the key and the other gets 404.
707
+ - **Five-minute TTL.** A link code lives 5 minutes (`deviceLink.ttlMs`). Every decision uses the
708
+ server's clock, carried into the statement that moves the link; `expiresAtMillis` is for the
709
+ countdown on screen and nothing else. Issuing again from the same device expires the previous
710
+ link, so there is one live link per issuing key.
711
+ - **Only the issuing key can confirm.** Status, confirm, deny and cancel are bound to the key that
712
+ signed `issue` — not merely the account. Another device of the same account, or another account,
713
+ is answered 404, exactly as for a link that never existed. The link also dies with that key: once
714
+ it is revoked, signed out or past its own expiry, every call on the link answers expired — and
715
+ confirm and the poll that registers the key re-check the issuing key inside the statement that
716
+ moves the link, under a lock on the key row, so a sign-out landing at the same moment is never
717
+ read around. A global revocation (`revoke-all`, even the kind that spares the calling device, a
718
+ password change, a deletion request) expires the account's links as well.
719
+ - **The match number, and its limit, stated plainly.** Redeem answers the new device with a number
720
+ from 10 to 99; the issuer is shown it among two other distinct numbers, in an order fixed when the
721
+ code was redeemed. The number is what ties the device the issuer is looking at to the device that
722
+ redeemed the code: someone who read the code off the issuer's screen redeems it on a phone the
723
+ issuer cannot see, so the issuer has no number to match. **A person who taps without looking at
724
+ the new device picks the right number one time in three.** That is the whole of the odds, because
725
+ one wrong pick denies the link — there is no second try, and the new device is told it was refused.
726
+ Typing the number would be stronger; three to tap is the trade taken for a flow people finish.
727
+ - **The new device's key is registered only after a confirm.** It is parked in
728
+ `spfn_auth.device_links`, not in `user_public_keys`, until the poll after the right pick moves it
729
+ over. A key parked on a link that was denied, cancelled, or expired can never sign anything and
730
+ can never be collected.
731
+ - **A redeemed, spent or never-issued code answer alike** (`DeviceLinkNotFoundError`, 404), so a
732
+ guesser cannot learn a code was real. A code that died of age answers 400.
733
+ - **Rate limits.** `issue` per account (10/min, and 50/min per IP); `redeem` and `poll` per IP
734
+ (10/min and 30/min); `status` per account (30/min, 150/min per IP) and `confirm` / `deny` /
735
+ `cancel` per account (10/min, 50/min per IP) — the device-code policies' sizes.
736
+ - **The device code is stored only as a SHA-256 hash**, returned once, as in device-code login.
737
+ Nothing in the flow logs a user code, device code, match number or key.
738
+ - **Long polls hold no transaction.** Both `status` (the issuer, while the link waits on the other
739
+ device) and `poll` (the new device, while it waits on the pick) take `waitMillis` and wait exactly
740
+ as the device-code poll does: ahead of the transaction, capped at `deviceAuth.maxWaitMs`, at most
741
+ three requests waiting on one link, a re-read every second for a change committed on another
742
+ instance, every transition waking both after commit, and a shutdown ending every wait with the
743
+ current answer.
744
+ - **`redeem` bounds what it stores** exactly as `device/start` does — it is the other route that
745
+ takes key material from a caller who cannot authenticate.
746
+
747
+ One knob of its own; the poll interval and long-poll cap are `deviceAuth`'s:
748
+
749
+ ```typescript
750
+ createAuthLifecycle({
751
+ deviceLink: {
752
+ ttlMs: 5 * 60 * 1000, // how long a link code lives. default 5 minutes
753
+ },
754
+ })
755
+ ```
756
+
757
+ The mobile contract carries the new device's half — `auth.deviceLink.redeem` and
758
+ `auth.deviceLink.poll`, since contract 0.13.2. The issuer's five routes run on the signed-in
759
+ device and are not on that surface.
760
+
619
761
  ### Registered devices (key management)
620
762
 
621
763
  A [passkey](#passkeys-webauthn) is **not** one of these keys: it is a credential that proves
@@ -2172,8 +2314,8 @@ whoever opened a link in a mailbox, so it is the notice to send the owner.
2172
2314
 
2173
2315
  `authDeviceRegisteredEvent` (`auth.device.registered`) fires after commit whenever a device key is
2174
2316
  registered on an account, on every channel that registers one — `channel` says which: `register`,
2175
- `signup-link`, `invitation`, `password`, `oauth`, `oauth-native`, `device-code`, `password-reset`
2176
- or `passkey`. It carries `userId`, `keyId`, `algorithm`, a 12-character `fingerprintPrefix`,
2317
+ `signup-link`, `invitation`, `password`, `oauth`, `oauth-native`, `device-code`, `device-link`,
2318
+ `password-reset` or `passkey`. It carries `userId`, `keyId`, `algorithm`, a 12-character `fingerprintPrefix`,
2177
2319
  `createdAtMillis`, and whatever the registration knew about the device: `deviceName?`, `platform?`,
2178
2320
  `ip?` and `userAgent?` — the web OAuth callback has neither label, because the sealed state does
2179
2321
  not carry them. Subscribe to tell the owner a device was added: a login event says a session began
@@ -2521,6 +2663,8 @@ Every operation in the exported bundle carries `since` — the contract version
2521
2663
  | `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
2522
2664
  | `core.time` | 0.9.0 |
2523
2665
  | `auth.device.start`, `auth.device.poll`, `auth.device.info`, `auth.device.approve`, `auth.device.deny` | 0.10.0 |
2666
+ | `auth.mfa.verify`, `auth.mfa.status` | 0.13.0 |
2667
+ | `auth.deviceLink.redeem`, `auth.deviceLink.poll` | 0.13.2 |
2524
2668
 
2525
2669
  - **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
2526
2670
  contract version passes or refuses the whole surface, so these three fields change no verdict here.
@@ -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.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;
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' | 'auth.deviceLink.redeem' | 'auth.deviceLink.poll' | typeof CORE_TIME_OPERATION_ID;
402
402
  method: 'GET' | 'POST';
403
403
  path: string;
404
404
  /**
@@ -482,6 +482,13 @@ declare const CONTRACT_OPERATIONS: readonly ContractOperation[];
482
482
  * which is what lets the server read the approving account from the caller
483
483
  * rather than from the request body.
484
484
  *
485
+ * Device link is device-code login's mirror image, and only the new device's
486
+ * half of it is here: `redeem` and `poll`, both unproven for the same reason.
487
+ * The issuer's five operations — issue, status, confirm, deny, cancel — are
488
+ * bound to the key that signed `issue`, and the device that runs them is the
489
+ * already-signed-in one showing a code, not the phone this contract generates
490
+ * a client for. They stay REST routes, as the `mfa/*` enrolment routes do.
491
+ *
485
492
  * `auth.mfa.verify` is unproven for the same reason the sign-ins are: the key it
486
493
  * activates is not usable until it succeeds, so there is nothing to sign the
487
494
  * call with. It is the only way to finish a sign-in that answered
@@ -1153,6 +1153,28 @@ var AUTH_SURFACE_OPERATIONS = [
1153
1153
  requestType: "DenyDeviceAuthRequest",
1154
1154
  summary: "Refuses the waiting device. Answers 204 with no body, so it names no response type.",
1155
1155
  since: "0.10.0"
1156
+ },
1157
+ {
1158
+ id: "auth.deviceLink.redeem",
1159
+ method: "POST",
1160
+ path: "/_auth/device/link/redeem",
1161
+ authProfile: "none",
1162
+ requiresSession: false,
1163
+ requestType: "RedeemDeviceLinkRequest",
1164
+ responseType: "RedeemDeviceLinkResponse",
1165
+ summary: "Parks a new device's public key on the code a signed-in device shows, and returns the match number to show.",
1166
+ since: "0.13.2"
1167
+ },
1168
+ {
1169
+ id: "auth.deviceLink.poll",
1170
+ method: "POST",
1171
+ path: "/_auth/device/link/poll",
1172
+ authProfile: "none",
1173
+ requiresSession: false,
1174
+ requestType: "PollDeviceLinkRequest",
1175
+ responseType: "PollDeviceAuthResponse",
1176
+ summary: "Asks whether the issuer picked the match number; the approved answer is the login it produced.",
1177
+ since: "0.13.2"
1156
1178
  }
1157
1179
  ];
1158
1180
  var ContractTypeError = class extends Error {
@@ -1419,7 +1441,7 @@ function isAppKind(kind) {
1419
1441
  }
1420
1442
 
1421
1443
  // src/server/client-proof/contract-bundle.ts
1422
- var CONTRACT_VERSION = "0.13.1";
1444
+ var CONTRACT_VERSION = "0.13.2";
1423
1445
  var CONTRACT_MAJOR = 0;
1424
1446
  var CONTRACT_SUPPORTED_RANGE = ">=0.13.0 <0.14.0";
1425
1447
  function required(name, type) {
@@ -1816,6 +1838,39 @@ var CONTRACT_TYPES = [
1816
1838
  fields: [
1817
1839
  required("userCode", "string")
1818
1840
  ]
1841
+ },
1842
+ /**
1843
+ * `StartDeviceAuthRequest` with the code it redeems in front: the same key
1844
+ * material under the same bounds, since this is the other place a caller
1845
+ * with nothing to authenticate parks a key before anyone agreed to it.
1846
+ */
1847
+ {
1848
+ name: "RedeemDeviceLinkRequest",
1849
+ fields: [
1850
+ required("userCode", "string"),
1851
+ required("publicKey", "string"),
1852
+ required("keyId", "string"),
1853
+ required("fingerprint", "string"),
1854
+ optional("algorithm", "KeyAlgorithm"),
1855
+ optional("deviceName", "string"),
1856
+ optional("platform", "KeyPlatform")
1857
+ ]
1858
+ },
1859
+ {
1860
+ name: "RedeemDeviceLinkResponse",
1861
+ fields: [
1862
+ required("deviceCode", "string"),
1863
+ required("matchNumber", "integer"),
1864
+ required("expiresAtMillis", "integer"),
1865
+ required("intervalMillis", "integer")
1866
+ ]
1867
+ },
1868
+ {
1869
+ name: "PollDeviceLinkRequest",
1870
+ fields: [
1871
+ required("deviceCode", "string"),
1872
+ optional("waitMillis", "integer")
1873
+ ]
1819
1874
  }
1820
1875
  ];
1821
1876
  var CONTRACT_ENUMS = [