@saeris/hanko 0.0.0 → 0.2.0
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/CHANGELOG.md +22 -0
- package/LICENSE.md +21 -0
- package/README.md +605 -0
- package/dist/approve/index.d.mts +318 -0
- package/dist/approve/index.d.mts.map +1 -0
- package/dist/approve/index.mjs +393 -0
- package/dist/approve/index.mjs.map +1 -0
- package/dist/client/index.d.mts +101 -0
- package/dist/client/index.d.mts.map +1 -0
- package/dist/client/index.mjs +215 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/codes-Ba_qYH6u.mjs +93 -0
- package/dist/codes-Ba_qYH6u.mjs.map +1 -0
- package/dist/handlers.d.mts +113 -0
- package/dist/handlers.d.mts.map +1 -0
- package/dist/handlers.mjs +194 -0
- package/dist/handlers.mjs.map +1 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +345 -0
- package/dist/index.mjs.map +1 -0
- package/dist/linking-DcQSMgem.mjs +177 -0
- package/dist/linking-DcQSMgem.mjs.map +1 -0
- package/dist/linking-nKoayyHf.d.mts +133 -0
- package/dist/linking-nKoayyHf.d.mts.map +1 -0
- package/dist/machine-CRHKjtoP.d.mts +223 -0
- package/dist/machine-CRHKjtoP.d.mts.map +1 -0
- package/dist/machine-D_5DAFxi.mjs +155 -0
- package/dist/machine-D_5DAFxi.mjs.map +1 -0
- package/dist/qr.d.mts +58 -0
- package/dist/qr.d.mts.map +1 -0
- package/dist/qr.mjs +27 -0
- package/dist/qr.mjs.map +1 -0
- package/dist/scan/index.d.mts +381 -0
- package/dist/scan/index.d.mts.map +1 -0
- package/dist/scan/index.mjs +409 -0
- package/dist/scan/index.mjs.map +1 -0
- package/dist/scan/worker.d.mts +2 -0
- package/dist/scan/worker.mjs +2 -0
- package/dist/server-BhoYRkCm.d.mts +257 -0
- package/dist/server-BhoYRkCm.d.mts.map +1 -0
- package/dist/stores/kv.d.mts +64 -0
- package/dist/stores/kv.d.mts.map +1 -0
- package/dist/stores/kv.mjs +87 -0
- package/dist/stores/kv.mjs.map +1 -0
- package/dist/stores/memory.d.mts +22 -0
- package/dist/stores/memory.d.mts.map +1 -0
- package/dist/stores/memory.mjs +42 -0
- package/dist/stores/memory.mjs.map +1 -0
- package/dist/types-BvBIFPH6.mjs +7 -0
- package/dist/types-BvBIFPH6.mjs.map +1 -0
- package/dist/types-C82lb-zX.d.mts +82 -0
- package/dist/types-C82lb-zX.d.mts.map +1 -0
- package/dist/worker-BdwaK1uX.mjs +5291 -0
- package/dist/worker-BdwaK1uX.mjs.map +1 -0
- package/dist/worker-DxbdBA2z.d.mts +164 -0
- package/dist/worker-DxbdBA2z.d.mts.map +1 -0
- package/package.json +116 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
<sub>2026-09-02</sub>
|
|
5
|
+
|
|
6
|
+
- Add a zero-dependency QR decoder, and scan with it.
|
|
7
|
+
The library previously delegated scanning to a third-party dependency. It now
|
|
8
|
+
ships its own decoder: pixels in, string out, no camera and no DOM, so the same
|
|
9
|
+
code runs in a browser, on a worker, on a server, or in a test.
|
|
10
|
+
Recognition is measured against the 718-image BoofCV benchmark, where it reads
|
|
11
|
+
**74.4%**. Published results on the same corpus are BoofCV 60.69%, ZBar 38.95%,
|
|
12
|
+
ZXing 31.87% and jsQR 24.8%.
|
|
13
|
+
- `@saeris/hanko/scan` — `createQrDecoder` for a synchronous decode, and
|
|
14
|
+
`createProgressiveScanner` for a camera, which spreads the retry ladder
|
|
15
|
+
across frames rather than cramming it into one.
|
|
16
|
+
- `@saeris/hanko/scan/worker` — `serveDecoder`, to run a decode off the main
|
|
17
|
+
thread. A viewfinder needs this: the ladder deliberately outruns its own time
|
|
18
|
+
budget, so a synchronous decode stalls the preview on exactly the frames
|
|
19
|
+
someone is lining up.
|
|
20
|
+
Reed-Solomon correction also accepts erasures — damage whose position is known
|
|
21
|
+
but whose value is not — which is worth double the capacity when the imaging
|
|
22
|
+
layer can say where the damage is.
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Drake Costa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
# hanko
|
|
2
|
+
|
|
3
|
+
QR-assisted device sign-in for screens without a keyboard — TVs, kiosks, set-top
|
|
4
|
+
boxes. An implementation of [RFC 8628][rfc], the OAuth 2.0 Device Authorization
|
|
5
|
+
Grant, which is the flow behind Plex, Steam, and Discord's TV sign-in.
|
|
6
|
+
|
|
7
|
+
The device shows a short code and a QR. The user authorizes on their phone. The
|
|
8
|
+
device polls until it hears back, then signs in.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌──────────────────────────────┐
|
|
12
|
+
│ Sign in │
|
|
13
|
+
│ │
|
|
14
|
+
│ Visit example.com/link │ ▌▌▌ ▌ ▌▌▌
|
|
15
|
+
│ and enter this code │ OR ▌ ▌▌▌▌ ▌▌
|
|
16
|
+
│ │ ▌▌ ▌ ▌▌▌▌
|
|
17
|
+
│ W D J B - M J H T │ ▌▌▌▌ ▌ ▌▌
|
|
18
|
+
└──────────────────────────────┘
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Zero dependencies beyond [`etiket`][etiket] for QR rendering. Runs anywhere with
|
|
22
|
+
WinterTC primitives: Node, browsers, Deno, Bun, Cloudflare Workers.
|
|
23
|
+
|
|
24
|
+
## Three places this flow lives
|
|
25
|
+
|
|
26
|
+
hanko ships a baseline for each, and no UI components for any of them — state
|
|
27
|
+
and lifecycle hooks instead, so React, Vue, Svelte, Solid, Angular, and React
|
|
28
|
+
Native each bind it with their own conventions.
|
|
29
|
+
|
|
30
|
+
| Entry | Runs on | Gives you |
|
|
31
|
+
| ----------------------------- | ---------------------- | ------------------------------------------ |
|
|
32
|
+
| `@saeris/hanko` + `/handlers` | your API | grant lifecycle, `Request`→`Response` glue |
|
|
33
|
+
| `@saeris/hanko/client` | the device signing in | poll loop, QR rendering |
|
|
34
|
+
| `@saeris/hanko/approve` | the device granting it | QR reading, confirmation challenges |
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
yarn add @saeris/hanko
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
hanko has **no runtime dependencies**. Its job is RFC 8628 — the grant
|
|
43
|
+
lifecycle — and that is all it ships.
|
|
44
|
+
|
|
45
|
+
Drawing a QR is a different class of problem, so it lives behind
|
|
46
|
+
`@saeris/hanko/qr` and defers to [`etiket`](https://www.npmjs.com/package/etiket),
|
|
47
|
+
declared as an optional peer. Add it only if you render the device screen:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
yarn add etiket
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Server
|
|
54
|
+
|
|
55
|
+
hanko owns the grant lifecycle and nothing else. It never mints sessions or
|
|
56
|
+
tokens: `approve()` records an opaque `subject`, a successful poll hands it
|
|
57
|
+
back, and your app issues whatever credential it already knows how to issue.
|
|
58
|
+
That is what lets it sit alongside Better-Auth or Supabase rather than competing
|
|
59
|
+
with them.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { HankoServer } from "@saeris/hanko";
|
|
63
|
+
import { MemoryDeviceGrantStore } from "@saeris/hanko/stores/memory";
|
|
64
|
+
|
|
65
|
+
const hanko = new HankoServer({
|
|
66
|
+
store: new MemoryDeviceGrantStore(),
|
|
67
|
+
verificationUri: `https://example.com/link`
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// POST /device/authorize — the device starts here
|
|
71
|
+
const grant = await hanko.requestAuthorization();
|
|
72
|
+
// → { device_code, user_code, verification_uri, verification_uri_complete, ... }
|
|
73
|
+
|
|
74
|
+
// POST /device/token — the device polls here
|
|
75
|
+
const result = await hanko.poll(deviceCode);
|
|
76
|
+
if (result.status === `approved`) {
|
|
77
|
+
const session = await createSession(result.subject);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// POST /link — the phone approves here, after the user confirms the code
|
|
81
|
+
await hanko.approve(userCode, session.userId);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Rendering the screen
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// From the /qr subpath, not the barrel: this is the one entry point that
|
|
88
|
+
// needs a dependency, and a server that only issues grants never pays for it.
|
|
89
|
+
import { renderDeviceQr } from "@saeris/hanko/qr";
|
|
90
|
+
|
|
91
|
+
const svg = renderDeviceQr(grant.verification_uri_complete, { size: 512 });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Defaults are tuned for a TV viewed from across a room. Error correction is `M`,
|
|
95
|
+
not `H`: higher correction needs more modules to encode the same URL, so at a
|
|
96
|
+
fixed size each module gets smaller — and on a clean screen, module size matters
|
|
97
|
+
more than damage tolerance.
|
|
98
|
+
|
|
99
|
+
**Show the code on both screens, and let the user compare.**
|
|
100
|
+
[RFC 8628 §3.3.1][rfc] is explicit about the mitigation:
|
|
101
|
+
|
|
102
|
+
> The server SHOULD display the `user_code` to the user and ask them to verify
|
|
103
|
+
> that it matches the `user_code` being displayed on the device to confirm they
|
|
104
|
+
> are authorizing the correct device.
|
|
105
|
+
|
|
106
|
+
So `createApprovalHandler` returns the code, and the approval screen shows it
|
|
107
|
+
next to which client is asking. The check is a **visual comparison against a
|
|
108
|
+
physically separate screen** — not a memory test. Making someone re-type a code
|
|
109
|
+
they can see proves nothing extra and is friction no production implementation
|
|
110
|
+
of this flow imposes.
|
|
111
|
+
|
|
112
|
+
`device_code` is never returned. That one is the bearer credential.
|
|
113
|
+
|
|
114
|
+
## Device
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { DeviceAuthClient } from "@saeris/hanko/client";
|
|
118
|
+
|
|
119
|
+
const client = new DeviceAuthClient({
|
|
120
|
+
tokenUrl: `https://example.com/device/token`,
|
|
121
|
+
deviceCode: grant.device_code,
|
|
122
|
+
interval: grant.interval,
|
|
123
|
+
expiresIn: grant.expires_in,
|
|
124
|
+
hooks: {
|
|
125
|
+
onTransition: (from, to) => render(to),
|
|
126
|
+
onSlowDown: (seconds) => console.log(`slowing to ${seconds}s`)
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const outcome = await client.run(signal);
|
|
131
|
+
// → { status: "authorized", tokens } | "denied" | "expired" | "aborted"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Polling only — no SSE or WebSocket. A sign-in screen may stay powered on for
|
|
135
|
+
days, and a persistent connection is one more thing to leak and reconnect on
|
|
136
|
+
hardware you cannot attach a profiler to.
|
|
137
|
+
|
|
138
|
+
For the common case where you only want the outcome:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { pollUntilAuthorized } from "@saeris/hanko/client";
|
|
142
|
+
|
|
143
|
+
const outcome = await pollUntilAuthorized({
|
|
144
|
+
tokenUrl,
|
|
145
|
+
deviceCode,
|
|
146
|
+
interval,
|
|
147
|
+
expiresIn
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Approving device
|
|
152
|
+
|
|
153
|
+
The phone that is already signed in. Two topologies exist in the wild and hanko
|
|
154
|
+
supports both through one flow:
|
|
155
|
+
|
|
156
|
+
- **Plex**: the OS camera opens `verification_uri_complete` in a browser. The
|
|
157
|
+
code arrives in the query string — no scanning step.
|
|
158
|
+
- **Discord / Steam**: the app scans in-place and the user never leaves it.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { ApprovalClient, codeEntryChallenge } from "@saeris/hanko/approve";
|
|
162
|
+
|
|
163
|
+
const client = new ApprovalClient({
|
|
164
|
+
resolve: async (code) =>
|
|
165
|
+
await fetch(`/link?user_code=${code}`).then((r) =>
|
|
166
|
+
r.ok ? r.json() : null
|
|
167
|
+
),
|
|
168
|
+
submit: async (code, approved) => {
|
|
169
|
+
await fetch(`/link`, {
|
|
170
|
+
method: `POST`,
|
|
171
|
+
body: new URLSearchParams({ user_code: code, approved: String(approved) })
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
challenge: codeEntryChallenge(),
|
|
175
|
+
hooks: { onTransition: (from, to) => render(to) }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Plex path — code came from the URL
|
|
179
|
+
await client.submitCode(new URL(location.href).searchParams.get(`user_code`));
|
|
180
|
+
|
|
181
|
+
// Discord/Steam path — scan frames until one carries a code
|
|
182
|
+
client.startScanning();
|
|
183
|
+
await client.scan(videoElement);
|
|
184
|
+
|
|
185
|
+
// Then, once the user answers the challenge
|
|
186
|
+
await client.confirm(typedCode);
|
|
187
|
+
await client.approve();
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`approve()` refuses until the challenge passes. `deny()` never does — a user who
|
|
191
|
+
cannot confirm a code is the one most likely to be looking at a phishing
|
|
192
|
+
attempt, and they must always be able to say no.
|
|
193
|
+
|
|
194
|
+
### Reading QR codes
|
|
195
|
+
|
|
196
|
+
hanko defines a two-method `QrScanner` interface and lets you bring a decoder.
|
|
197
|
+
It deliberately does not pick one for you.
|
|
198
|
+
|
|
199
|
+
**`BarcodeDetector` is not a web standard.** It is a WICG incubation that MDN
|
|
200
|
+
flags as outside Baseline; Safari has never shipped it, and no vendor has
|
|
201
|
+
committed to it. Building against it directly means the scanner silently does
|
|
202
|
+
nothing on an iPhone — the device most people approve from.
|
|
203
|
+
|
|
204
|
+
The recommendation is [`qr-scanner`][qr-scanner]: ~6 kB gzipped,
|
|
205
|
+
self-contained, and it uses a native `BarcodeDetector` where one exists and its
|
|
206
|
+
own worker otherwise. It owns the camera too, which is most of the work.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import QrScanner from "qr-scanner";
|
|
210
|
+
import { parseApprovalLink } from "@saeris/hanko/approve";
|
|
211
|
+
|
|
212
|
+
const scanner = new QrScanner(
|
|
213
|
+
videoElement,
|
|
214
|
+
({ data }) => {
|
|
215
|
+
const link = parseApprovalLink(data);
|
|
216
|
+
if (link) void client.submitCode(link.userCode);
|
|
217
|
+
},
|
|
218
|
+
{ preferredCamera: `environment`, maxScansPerSecond: 10 }
|
|
219
|
+
);
|
|
220
|
+
await scanner.start();
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Alternatives, and what they cost:
|
|
224
|
+
|
|
225
|
+
| Decoder | Size | Note |
|
|
226
|
+
| ------------------------ | ------------ | -------------------------------------------------------- |
|
|
227
|
+
| `qr-scanner` | ~6 kB gz | self-contained, manages the camera |
|
|
228
|
+
| `barcode-detector` | ~1.5 MB WASM | more formats, but fetches its WASM from a CDN at runtime |
|
|
229
|
+
| native `BarcodeDetector` | 0 | absent on Safari; use `createBarcodeDetectorScanner()` |
|
|
230
|
+
| `expo-camera` | — | React Native, where no web API exists |
|
|
231
|
+
|
|
232
|
+
Anything satisfying `QrScanner` works, including a React Native camera:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const scanner: QrScanner = {
|
|
236
|
+
detect: async (frame) => [{ rawValue: await scanWithExpoCamera(frame) }]
|
|
237
|
+
};
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`createBarcodeDetectorScanner` restricts detection to `qr_code`. An EAN-13 in
|
|
241
|
+
the same frame would otherwise be posted to your approval endpoint as though it
|
|
242
|
+
were a user code.
|
|
243
|
+
|
|
244
|
+
### Confirmation challenges
|
|
245
|
+
|
|
246
|
+
Scanning a QR removes the moment where the user would have noticed the code was
|
|
247
|
+
wrong. [RFC 8628 §5.4][rfc-security] asks you to put it back. How much friction
|
|
248
|
+
that deserves is a product decision, so it is pluggable:
|
|
249
|
+
|
|
250
|
+
| Strategy | Pattern | Friction |
|
|
251
|
+
| ------------------------------------- | -------------------------- | ------------------- |
|
|
252
|
+
| `noChallenge()` | Discord, Steam | one tap |
|
|
253
|
+
| `tripletChallenge({ generate })` | Google mobile approval | one tap, real check |
|
|
254
|
+
| `codeEntryChallenge()` | GitHub sudo, Plex | types the code |
|
|
255
|
+
| `platformChallenge({ authenticate })` | FaceID, WebAuthn, passcode | biometric |
|
|
256
|
+
|
|
257
|
+
`allOf([...])` composes them. The pairing worth knowing: a biometric proves
|
|
258
|
+
possession of the phone, a code check proves the user is looking at the screen
|
|
259
|
+
being authorized. Neither covers both.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
import {
|
|
263
|
+
allOf,
|
|
264
|
+
codeEntryChallenge,
|
|
265
|
+
platformChallenge
|
|
266
|
+
} from "@saeris/hanko/approve";
|
|
267
|
+
|
|
268
|
+
const challenge = allOf([
|
|
269
|
+
platformChallenge({
|
|
270
|
+
authenticate: () => LocalAuthentication.authenticateAsync()
|
|
271
|
+
}),
|
|
272
|
+
codeEntryChallenge()
|
|
273
|
+
]);
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
For a public screen — a taproom TV anyone can walk up to — do not use
|
|
277
|
+
`noChallenge()`.
|
|
278
|
+
|
|
279
|
+
## Opening the app from a scanned code
|
|
280
|
+
|
|
281
|
+
One QR should open the native app when it is installed, and the web page when it
|
|
282
|
+
is not — without the device ever showing an error.
|
|
283
|
+
|
|
284
|
+
That rules out custom schemes as the QR payload. A `beerjournal://` code read by
|
|
285
|
+
the OS camera on a phone without the app fails silently and unrecoverably: the
|
|
286
|
+
user sees "cannot open" with nowhere to go. **Universal Links (iOS) and App
|
|
287
|
+
Links (Android)** solve this by making the payload an ordinary `https://` URL
|
|
288
|
+
that the OS _routes_ to the app when the domain and app are associated.
|
|
289
|
+
|
|
290
|
+
So the QR keeps encoding `verification_uri_complete` unchanged. The routing
|
|
291
|
+
lives in association files served from the same origin:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import { createWellKnownHandler } from "@saeris/hanko/handlers";
|
|
295
|
+
|
|
296
|
+
const wellKnown = createWellKnownHandler({
|
|
297
|
+
appleAppIds: [`QQ57RJ5UTD.gg.saeris.beerjournal`],
|
|
298
|
+
androidPackageName: `gg.saeris.beerjournal`,
|
|
299
|
+
// SHA-256 of the PLAY-signed cert, not your local keystore.
|
|
300
|
+
androidFingerprints: [`AA:BB:...`]
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Both files must be served from the **same origin** as the approval page, over
|
|
305
|
+
HTTPS, **with no redirects**. A redirect or a wrong content-type makes the
|
|
306
|
+
association fail silently, with nothing in any log — the usual reason universal
|
|
307
|
+
links "just don't work".
|
|
308
|
+
|
|
309
|
+
### Expo
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
import { expoLinkingConfig } from "@saeris/hanko";
|
|
313
|
+
|
|
314
|
+
// Merge into app.json
|
|
315
|
+
expoLinkingConfig({ origin: `https://example.com`, scheme: `beerjournal` });
|
|
316
|
+
// → { scheme, ios: { associatedDomains: ["applinks:example.com"] },
|
|
317
|
+
// android: { intentFilters: [{ autoVerify: true, ... }] } }
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Two things that silently break this:
|
|
321
|
+
|
|
322
|
+
- `associatedDomains` takes **no protocol** — `applinks:example.com`, never
|
|
323
|
+
`applinks:https://example.com`.
|
|
324
|
+
- `autoVerify: true` is what makes Android fetch `assetlinks.json` and open the
|
|
325
|
+
app without a chooser dialog. Without it the link is registered and
|
|
326
|
+
practically useless.
|
|
327
|
+
|
|
328
|
+
**Universal links do not work in Expo Go.** The entitlement is registered at
|
|
329
|
+
build time, so this needs a development or production build. A project pinned to
|
|
330
|
+
Expo Go uses the web fallback until it moves to dev builds — which is a
|
|
331
|
+
sequencing constraint, not a blocker: the same QR already works.
|
|
332
|
+
|
|
333
|
+
Receiving the link:
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
import * as Linking from "expo-linking";
|
|
337
|
+
import { parseApprovalLink } from "@saeris/hanko/approve";
|
|
338
|
+
|
|
339
|
+
const initial = await Linking.getInitialURL();
|
|
340
|
+
const link = initial && parseApprovalLink(initial, { scheme: `beerjournal` });
|
|
341
|
+
if (link) await client.submitCode(link.userCode);
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### PWAs on both ends
|
|
345
|
+
|
|
346
|
+
The whole flow works PWA-to-PWA, with one caveat worth knowing up front.
|
|
347
|
+
|
|
348
|
+
**The signing-in device** (the TV) is the easy half: it renders a code and an
|
|
349
|
+
SVG QR, then polls. No camera, no install, no platform APIs. A Fire Stick or Pi
|
|
350
|
+
browser runs it as-is.
|
|
351
|
+
|
|
352
|
+
**The approving device** is where PWAs get thin:
|
|
353
|
+
|
|
354
|
+
| Capability | Status |
|
|
355
|
+
| ----------------------------------- | ------------------------------------- |
|
|
356
|
+
| Receive an https link | works everywhere |
|
|
357
|
+
| `launch_handler: navigate-existing` | Chromium only; falls back cleanly |
|
|
358
|
+
| Camera scanning (`getUserMedia`) | works, but see below |
|
|
359
|
+
| Being the OS camera's target | **installed PWAs cannot claim links** |
|
|
360
|
+
|
|
361
|
+
The last row is the real constraint: a PWA cannot register for Universal Links.
|
|
362
|
+
The OS camera opens the _browser_, not your installed PWA. `launch_handler` only
|
|
363
|
+
controls what happens once the link reaches your origin.
|
|
364
|
+
|
|
365
|
+
Camera access inside an installed iOS PWA was broken from iOS 18 until 18.4, and
|
|
366
|
+
permission still is not persisted the way it is in Safari proper. So on the
|
|
367
|
+
approving side, **the typed-code path is the reliability floor, not a nicety** —
|
|
368
|
+
build the scanner as an enhancement over it, never the only way in.
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { consumeLaunchTarget, parseApprovalLink } from "@saeris/hanko/approve";
|
|
372
|
+
|
|
373
|
+
// Reads launchQueue where supported. Safari and Firefox have none, and the
|
|
374
|
+
// `location.href` fallback is opt-in (`fallbackToLocation: true`) because it
|
|
375
|
+
// cannot tell a launch from an ordinary page load — it fires on every visit.
|
|
376
|
+
// Turn it on only for a page reached exclusively by launch; otherwise a plain
|
|
377
|
+
// visit submits its own URL as a code and fails.
|
|
378
|
+
consumeLaunchTarget((href) => {
|
|
379
|
+
const link = parseApprovalLink(href);
|
|
380
|
+
if (link) void client.submitCode(link.userCode);
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Add `pwaLaunchHandler()` to your manifest so a scanned link reuses the open
|
|
385
|
+
window rather than stacking a second one behind it.
|
|
386
|
+
|
|
387
|
+
### What each device actually gets
|
|
388
|
+
|
|
389
|
+
| Approving device | Scanned QR opens | Notes |
|
|
390
|
+
| ------------------------------------- | --------------------------- | ------------------------- |
|
|
391
|
+
| Native app installed (dev/prod build) | the app, directly | best case |
|
|
392
|
+
| Native app absent | the web page | same QR, no error |
|
|
393
|
+
| Installed PWA | the browser, then your page | PWA cannot claim the link |
|
|
394
|
+
| Desktop browser | the web page | typed code only |
|
|
395
|
+
|
|
396
|
+
Every row reaches a working approval screen. That is the property worth
|
|
397
|
+
protecting — and the reason the payload stays an `https://` URL.
|
|
398
|
+
|
|
399
|
+
## Edge runtimes
|
|
400
|
+
|
|
401
|
+
The server half is stateless by construction: every request loads its grant,
|
|
402
|
+
applies one transition, and writes it back. Nothing is held between
|
|
403
|
+
invocations, so a flow survives its requests landing on different workers, or
|
|
404
|
+
on an instance frozen mid-flow.
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
// Cloudflare Workers
|
|
408
|
+
import { createHandlers } from "@saeris/hanko/handlers";
|
|
409
|
+
import { HankoServer } from "@saeris/hanko";
|
|
410
|
+
import { KvDeviceGrantStore, kvFromOptionsApi } from "@saeris/hanko/stores/kv";
|
|
411
|
+
|
|
412
|
+
export default {
|
|
413
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
414
|
+
const handlers = createHandlers({
|
|
415
|
+
server: new HankoServer({
|
|
416
|
+
store: new KvDeviceGrantStore({
|
|
417
|
+
kv: kvFromOptionsApi({
|
|
418
|
+
get: (k) => env.GRANTS.get(k),
|
|
419
|
+
set: (k, v, o) => env.GRANTS.put(k, v, o),
|
|
420
|
+
remove: (k) => env.GRANTS.delete(k)
|
|
421
|
+
})
|
|
422
|
+
}),
|
|
423
|
+
verificationUri: `https://example.com/link`
|
|
424
|
+
}),
|
|
425
|
+
// Read from YOUR session — never from the request body.
|
|
426
|
+
authenticate: (req) => getSession(req)?.userId ?? null,
|
|
427
|
+
createSession: (subject) => mintToken(subject),
|
|
428
|
+
rateLimit: (req, code) =>
|
|
429
|
+
limiter.check(req.headers.get(`CF-Connecting-IP`), code)
|
|
430
|
+
});
|
|
431
|
+
return handlers.fetch(request);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
The individual handlers — `authorize`, `token`, `approval` — are exported
|
|
437
|
+
separately for file-based routing (Astro, Next, SvelteKit, Vercel Functions).
|
|
438
|
+
|
|
439
|
+
### Multiple hostnames, one deployment
|
|
440
|
+
|
|
441
|
+
The QR has to encode the host the device is actually talking to. One deployment
|
|
442
|
+
is commonly reachable through several — a preview URL, a custom domain, a
|
|
443
|
+
tunnel during development — and a `verificationUri` fixed at construction time
|
|
444
|
+
points at only one of them. The failure is silent: the code renders correctly
|
|
445
|
+
and the phone lands nowhere.
|
|
446
|
+
|
|
447
|
+
`createAuthorizationHandler` therefore derives the origin per request from
|
|
448
|
+
`x-forwarded-host` and `x-forwarded-proto`, which every common proxy sets —
|
|
449
|
+
ngrok, Cloudflare, Vercel, nginx. The configured `verificationUri` remains the
|
|
450
|
+
fallback for direct requests that carry no forwarded headers.
|
|
451
|
+
|
|
452
|
+
```ts
|
|
453
|
+
createAuthorizationHandler({
|
|
454
|
+
server,
|
|
455
|
+
verificationPath: `/link`, // appended to the detected origin
|
|
456
|
+
trustForwardedHost: true // the default
|
|
457
|
+
});
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Set `trustForwardedHost: false` to always use the configured value. Worth doing
|
|
461
|
+
if your platform does not strip client-sent `x-forwarded-*` headers and you
|
|
462
|
+
would rather pin the origin: those headers are client-controllable in that
|
|
463
|
+
case. They are safe for building a URL the same client will visit — which is
|
|
464
|
+
all this does — but never use them for an authorization decision.
|
|
465
|
+
|
|
466
|
+
Calling the server directly takes the same override:
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
await server.requestAuthorization({
|
|
470
|
+
verificationUri: `https://${request.headers.get("x-forwarded-host")}/link`
|
|
471
|
+
});
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### Persistence
|
|
475
|
+
|
|
476
|
+
| Layer | Adapter |
|
|
477
|
+
| ----------------------------------- | ----------------------------------------------------------- |
|
|
478
|
+
| Upstash Redis, Vercel KV, `ioredis` | `KvDeviceGrantStore` + `kvFromOptionsApi({ ttlKey: "ex" })` |
|
|
479
|
+
| Cloudflare Workers KV | `kvFromOptionsApi({ ttlKey: "expirationTtl" })` |
|
|
480
|
+
| Deno KV | `KeyValueAdapter` directly |
|
|
481
|
+
| Supabase / Postgres | implement `DeviceGrantStore` (four methods) |
|
|
482
|
+
| Durable Objects | implement `DeviceGrantStore` over `ctx.storage` |
|
|
483
|
+
|
|
484
|
+
TTL does the pruning, with a grace window past the deadline so the server can
|
|
485
|
+
answer `expired_token` honestly instead of "unknown code" — which a client
|
|
486
|
+
cannot distinguish from a typo.
|
|
487
|
+
|
|
488
|
+
**Rate limiting is your job.** [§5.1][rfc-security] requires it, and hanko
|
|
489
|
+
cannot do it portably: an effective limiter needs the client IP, which lives in
|
|
490
|
+
a platform-specific header. The `rateLimit` seam exists so the requirement is
|
|
491
|
+
not silently skipped.
|
|
492
|
+
|
|
493
|
+
## State machines
|
|
494
|
+
|
|
495
|
+
Both halves are explicit state machines with declarative transition tables. The
|
|
496
|
+
transitions are data, so they can be read against the RFC side by side — and
|
|
497
|
+
illegal moves are impossible by construction rather than guarded against.
|
|
498
|
+
|
|
499
|
+
**Grant** (server):
|
|
500
|
+
|
|
501
|
+
```
|
|
502
|
+
pending ──APPROVE──▶ approved ──REDEEM──▶ consumed
|
|
503
|
+
│ │
|
|
504
|
+
├──DENY────▶ denied └──EXPIRE──▶ expired
|
|
505
|
+
└──EXPIRE──▶ expired
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Redemption, not approval, is what ends the flow — a `device_code` that stayed
|
|
509
|
+
redeemable after approval would be a replayable bearer credential. And an
|
|
510
|
+
approval nobody collected still expires.
|
|
511
|
+
|
|
512
|
+
**Poll** (device):
|
|
513
|
+
|
|
514
|
+
```
|
|
515
|
+
idle ──START──▶ waiting ──TICK──▶ polling
|
|
516
|
+
▲ │
|
|
517
|
+
└── pending ──────┤── SUCCESS ────────▶ authorized
|
|
518
|
+
slow_down ├── ACCESS_DENIED ──▶ denied
|
|
519
|
+
network error └── EXPIRED_TOKEN ──▶ expired
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
The interval lives in context, not state: `waiting` at 5s and `waiting` at 20s
|
|
523
|
+
are the same state. That separation keeps the two pacing rules distinct —
|
|
524
|
+
`slow_down` adds a fixed 5s permanently ([§3.5][rfc-token]), while a network
|
|
525
|
+
failure doubles with a cap. Conflating them either hammers a struggling server
|
|
526
|
+
or crawls when it only asked for a small delay.
|
|
527
|
+
|
|
528
|
+
**Approval** (the phone):
|
|
529
|
+
|
|
530
|
+
```
|
|
531
|
+
idle ──SCAN──▶ scanning ──CODE──▶ resolving ──RESOLVED──▶ confirming
|
|
532
|
+
│ │ │
|
|
533
|
+
└──CODE (from URL)───────────────────┘ │
|
|
534
|
+
└──REJECTED──▶ invalid │
|
|
535
|
+
▼
|
|
536
|
+
approved ◀──SUBMITTED── submitting ─┘
|
|
537
|
+
denied ◀──SUBMITTED──
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
`confirming` accepts `CHALLENGE_FAILED` back into itself: a mistyped code is a
|
|
541
|
+
retry, not a dead end. Both entry paths — scanned in-app or arriving by URL —
|
|
542
|
+
converge on the same confirmation, so the security-sensitive half is written
|
|
543
|
+
once.
|
|
544
|
+
|
|
545
|
+
All three machines are exported if you want to drive them yourself — persisting
|
|
546
|
+
grant state in a Durable Object, say, or rendering a screen straight from the
|
|
547
|
+
state:
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
import {
|
|
551
|
+
grantTransition,
|
|
552
|
+
pollTransition,
|
|
553
|
+
approvalTransition,
|
|
554
|
+
canTransitionGrant
|
|
555
|
+
} from "@saeris/hanko";
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
## Codes
|
|
559
|
+
|
|
560
|
+
Defaults follow the spec's worked example: 8 characters from a 20-consonant
|
|
561
|
+
alphabet, displayed as `WDJB-MJHT`. No vowels, so codes cannot spell words; no
|
|
562
|
+
digits, so there is no `0`/`O` or `1`/`l`/`I` confusion.
|
|
563
|
+
|
|
564
|
+
```ts
|
|
565
|
+
import { generateUserCode, NUMERIC_ALPHABET } from "@saeris/hanko";
|
|
566
|
+
|
|
567
|
+
generateUserCode(); // "WDJB-MJHT"
|
|
568
|
+
generateUserCode({ alphabet: NUMERIC_ALPHABET, length: 9 }); // "0194-5073-0"
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
A short code is a small keyspace, which is the deliberate trade for
|
|
572
|
+
readability. **[RFC 8628 §5.1][rfc-security] requires you to rate-limit
|
|
573
|
+
attempts** — the code alone is not brute-force resistant, and hanko does not
|
|
574
|
+
rate-limit for you. That belongs at your HTTP boundary, where you can see IPs.
|
|
575
|
+
|
|
576
|
+
## Storage
|
|
577
|
+
|
|
578
|
+
`DeviceGrantStore` is four methods, so adapters are small:
|
|
579
|
+
|
|
580
|
+
```ts
|
|
581
|
+
interface DeviceGrantStore {
|
|
582
|
+
create(grant: DeviceGrant): Promise<void> | void;
|
|
583
|
+
findByDeviceCode(
|
|
584
|
+
deviceCode: string
|
|
585
|
+
): Promise<DeviceGrant | null> | DeviceGrant | null;
|
|
586
|
+
findByUserCode(
|
|
587
|
+
userCode: string
|
|
588
|
+
): Promise<DeviceGrant | null> | DeviceGrant | null;
|
|
589
|
+
update(grant: DeviceGrant): Promise<void> | void;
|
|
590
|
+
prune?(now: number): Promise<void> | void;
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
The bundled `MemoryDeviceGrantStore` is for development only — state dies with
|
|
595
|
+
the process and is not shared across instances.
|
|
596
|
+
|
|
597
|
+
## License
|
|
598
|
+
|
|
599
|
+
MIT © [Drake Costa](https://saeris.gg)
|
|
600
|
+
|
|
601
|
+
[rfc]: https://datatracker.ietf.org/doc/html/rfc8628
|
|
602
|
+
[rfc-token]: https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
|
|
603
|
+
[rfc-security]: https://datatracker.ietf.org/doc/html/rfc8628#section-5
|
|
604
|
+
[etiket]: https://github.com/productdevbook/etiket
|
|
605
|
+
[qr-scanner]: https://github.com/nimiq/qr-scanner
|