@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 +146 -2
- package/dist/client-proof.d.ts +8 -1
- package/dist/client-proof.js +56 -1
- package/dist/client-proof.js.map +1 -1
- package/dist/config.d.ts +6 -6
- package/dist/errors.d.ts +104 -2
- package/dist/errors.js +69 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +389 -336
- package/dist/index.js +74 -0
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-BvRw8b8t.d.ts → machine-principals-B0bjs-0K.d.ts} +896 -285
- package/dist/server.d.ts +396 -174
- package/dist/server.js +1557 -679
- package/dist/server.js.map +1 -1
- package/migrations/20260926054221_friendly_hitman/migration.sql +28 -0
- package/migrations/20260926054221_friendly_hitman/snapshot.json +6691 -0
- package/package.json +1 -1
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`, `
|
|
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.
|
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.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
|
package/dist/client-proof.js
CHANGED
|
@@ -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.
|
|
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 = [
|