@spfn/auth 0.3.0-beta.26 → 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 +195 -9
- package/dist/client-proof.d.ts +8 -1
- package/dist/client-proof.js +58 -2
- 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 +504 -446
- package/dist/index.js +114 -40
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-CdEgxOB1.d.ts → machine-principals-B0bjs-0K.d.ts} +914 -285
- package/dist/server.d.ts +430 -175
- package/dist/server.js +1710 -655
- 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
|
@@ -104,12 +104,16 @@ Import it for its side-effect (it self-registers); it must run before the proxy
|
|
|
104
104
|
// app/api/rpc/[routeName]/route.ts
|
|
105
105
|
import '@spfn/auth/nextjs/api'; // side-effect: registers auth interceptors
|
|
106
106
|
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
107
|
-
import { authRouteMap } from '@spfn/auth';
|
|
108
107
|
import { routeMap } from '@/generated/route-map';
|
|
109
108
|
|
|
110
|
-
export const { GET, POST } = createRpcProxy({ routeMap
|
|
109
|
+
export const { GET, POST } = createRpcProxy({ routeMap });
|
|
111
110
|
```
|
|
112
111
|
|
|
112
|
+
No auth route map is merged: the generated `routeMap` carries the routes of every package
|
|
113
|
+
router the app router mounts with `.packages()`, `authRouter`'s included. `authRouteMap` is
|
|
114
|
+
still exported and `{ ...routeMap, ...authRouteMap }` is still harmless — the two hold the
|
|
115
|
+
same entries — but it is a no-op.
|
|
116
|
+
|
|
113
117
|
### 4. Run migrations
|
|
114
118
|
|
|
115
119
|
```bash
|
|
@@ -215,6 +219,13 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
215
219
|
| `getDeviceAuthInfo` | POST `/_auth/device/info` | yes | what device is asking, so the approval screen can show it |
|
|
216
220
|
| `approveDeviceAuth` | POST `/_auth/device/approve` | yes | let the waiting device in |
|
|
217
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 |
|
|
218
229
|
| `passkeyRegisterOptions` | POST `/_auth/passkeys/register/options` | yes | begin enrolling a passkey — see [Passkeys](#passkeys-webauthn) |
|
|
219
230
|
| `passkeyRegisterVerify` | POST `/_auth/passkeys/register/verify` | yes | verify the attestation and keep the credential |
|
|
220
231
|
| `passkeyLoginOptions` | POST `/_auth/passkeys/login/options` | public | begin a passkey sign-in; takes no identifier |
|
|
@@ -499,6 +510,31 @@ const answer = await authApi.pollDeviceAuth.call({ body: { deviceCode } });
|
|
|
499
510
|
// → { status: 'approved', userId, publicId, email?, phone?, passwordChangeRequired }
|
|
500
511
|
```
|
|
501
512
|
|
|
513
|
+
Or long-poll: send `waitMillis` and the server holds a pending request until the owner
|
|
514
|
+
answers or the wait runs out, so the device learns of an approval the moment it is made
|
|
515
|
+
instead of at its next tick.
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
let answer;
|
|
519
|
+
|
|
520
|
+
do
|
|
521
|
+
{
|
|
522
|
+
// Held up to 20s (the server's maxWaitMs caps it). A pending answer takes the time
|
|
523
|
+
// already waited off intervalMillis — 0 after a full wait, so ask again at once.
|
|
524
|
+
// An error ends the loop, as before.
|
|
525
|
+
answer = await authApi.pollDeviceAuth.call({ body: { deviceCode, waitMillis: 20_000 } });
|
|
526
|
+
|
|
527
|
+
if (answer.status === 'pending' && answer.intervalMillis > 0)
|
|
528
|
+
{
|
|
529
|
+
await new Promise(resolve => setTimeout(resolve, answer.intervalMillis));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
while (answer.status === 'pending');
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Keep the loop's sleep on `intervalMillis > 0`. It covers a server that answered without
|
|
536
|
+
waiting — an older one that ignores the field — so the loop never spins.
|
|
537
|
+
|
|
502
538
|
```typescript
|
|
503
539
|
// On the signed-in device — the user typed the code they read off the other screen.
|
|
504
540
|
const asking = await authApi.getDeviceAuthInfo.call({ body: { userCode } });
|
|
@@ -553,26 +589,175 @@ two ways in are indistinguishable.
|
|
|
553
589
|
who cannot authenticate, so `publicKey`, `keyId` and `fingerprint` carry length limits —
|
|
554
590
|
generous next to a real key (an RSA-2048 SPKI is 392 base64 characters against a 2048 limit)
|
|
555
591
|
and small next to the megabyte that would otherwise sit in a table no job clears.
|
|
592
|
+
- **A long poll holds no transaction.** The wait is route middleware in front of the poll's
|
|
593
|
+
`Transactional()`, so a waiting device does not pin a pooled connection, and the answer is
|
|
594
|
+
judged inside the transaction exactly as a poll without `waitMillis` is — same atomicity,
|
|
595
|
+
same database-error answers. Approve, deny and a global revocation wake a poll parked in the
|
|
596
|
+
same process after they commit. A poll parked on another instance re-reads its record every
|
|
597
|
+
second, so an approval committed elsewhere reaches it within about a second. A device that
|
|
598
|
+
hangs up mid-wait is not judged, so an approval it can no longer hear waits for its next poll;
|
|
599
|
+
at most three polls wait on one code at a time — a fourth is answered at once; and a server
|
|
600
|
+
that starts shutting down ends every wait with a pending answer rather than a cut connection.
|
|
556
601
|
- **Clock skew cannot affect this.** Every timestamp in the decision is the server's. The
|
|
557
602
|
`expiresAtMillis` in the start response is for the waiting device's countdown display, and
|
|
558
603
|
nothing the client believes about the time reaches the server's judgement.
|
|
559
604
|
|
|
560
|
-
|
|
561
|
-
|
|
605
|
+
Three knobs, resolved at lifecycle time rather than read per call — the first two are
|
|
606
|
+
announced to the waiting device in the start response:
|
|
562
607
|
|
|
563
608
|
```typescript
|
|
564
609
|
createAuthLifecycle({
|
|
565
610
|
deviceAuth: {
|
|
566
611
|
ttlMs: 10 * 60 * 1000, // how long a code lives. default 10 minutes
|
|
567
612
|
intervalMs: 5000, // poll interval the server asks for. default 5s
|
|
613
|
+
maxWaitMs: 20_000, // longest a long poll is held. default 20s
|
|
568
614
|
},
|
|
569
615
|
})
|
|
570
616
|
```
|
|
571
617
|
|
|
618
|
+
Keep `maxWaitMs` under the idle timeout of every proxy and load balancer in front of the
|
|
619
|
+
server. A long poll cut off by one reaches the device as a network error, not as a pending
|
|
620
|
+
answer — Google Cloud's load balancer closes a backend request at 30 seconds by default.
|
|
621
|
+
|
|
572
622
|
No job sweeps the table. Rows are judged by `expiresAt` whenever they are read or moved, so a
|
|
573
623
|
stale row authorizes nothing; it only keeps its user code out of circulation, and 31⁸ codes do
|
|
574
624
|
not run out.
|
|
575
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
|
+
|
|
576
761
|
### Registered devices (key management)
|
|
577
762
|
|
|
578
763
|
A [passkey](#passkeys-webauthn) is **not** one of these keys: it is a credential that proves
|
|
@@ -2129,8 +2314,8 @@ whoever opened a link in a mailbox, so it is the notice to send the owner.
|
|
|
2129
2314
|
|
|
2130
2315
|
`authDeviceRegisteredEvent` (`auth.device.registered`) fires after commit whenever a device key is
|
|
2131
2316
|
registered on an account, on every channel that registers one — `channel` says which: `register`,
|
|
2132
|
-
`signup-link`, `invitation`, `password`, `oauth`, `oauth-native`, `device-code`, `
|
|
2133
|
-
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`,
|
|
2134
2319
|
`createdAtMillis`, and whatever the registration knew about the device: `deviceName?`, `platform?`,
|
|
2135
2320
|
`ip?` and `userAgent?` — the web OAuth callback has neither label, because the sealed state does
|
|
2136
2321
|
not carry them. Subscribe to tell the owner a device was added: a login event says a session began
|
|
@@ -2478,6 +2663,8 @@ Every operation in the exported bundle carries `since` — the contract version
|
|
|
2478
2663
|
| `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
|
|
2479
2664
|
| `core.time` | 0.9.0 |
|
|
2480
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 |
|
|
2481
2668
|
|
|
2482
2669
|
- **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
|
|
2483
2670
|
contract version passes or refuses the whole surface, so these three fields change no verdict here.
|
|
@@ -3231,9 +3418,8 @@ export type AppRouter = typeof appRouter;
|
|
|
3231
3418
|
// app/api/rpc/[routeName]/route.ts
|
|
3232
3419
|
import '@spfn/auth/nextjs/api';
|
|
3233
3420
|
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
3234
|
-
import {
|
|
3235
|
-
|
|
3236
|
-
export const { GET, POST } = createRpcProxy({ routeMap: { ...routeMap, ...authRouteMap } });
|
|
3421
|
+
import { routeMap } from '@/generated/route-map'; // already holds authRouter's routes
|
|
3422
|
+
export const { GET, POST } = createRpcProxy({ routeMap });
|
|
3237
3423
|
|
|
3238
3424
|
// any client component
|
|
3239
3425
|
import { authApi } from '@spfn/auth';
|
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) {
|
|
@@ -1754,7 +1776,8 @@ var CONTRACT_TYPES = [
|
|
|
1754
1776
|
{
|
|
1755
1777
|
name: "PollDeviceAuthRequest",
|
|
1756
1778
|
fields: [
|
|
1757
|
-
required("deviceCode", "string")
|
|
1779
|
+
required("deviceCode", "string"),
|
|
1780
|
+
optional("waitMillis", "integer")
|
|
1758
1781
|
]
|
|
1759
1782
|
},
|
|
1760
1783
|
/**
|
|
@@ -1815,6 +1838,39 @@ var CONTRACT_TYPES = [
|
|
|
1815
1838
|
fields: [
|
|
1816
1839
|
required("userCode", "string")
|
|
1817
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
|
+
]
|
|
1818
1874
|
}
|
|
1819
1875
|
];
|
|
1820
1876
|
var CONTRACT_ENUMS = [
|