@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +605 -0
  4. package/dist/approve/index.d.mts +318 -0
  5. package/dist/approve/index.d.mts.map +1 -0
  6. package/dist/approve/index.mjs +393 -0
  7. package/dist/approve/index.mjs.map +1 -0
  8. package/dist/client/index.d.mts +101 -0
  9. package/dist/client/index.d.mts.map +1 -0
  10. package/dist/client/index.mjs +215 -0
  11. package/dist/client/index.mjs.map +1 -0
  12. package/dist/codes-Ba_qYH6u.mjs +93 -0
  13. package/dist/codes-Ba_qYH6u.mjs.map +1 -0
  14. package/dist/handlers.d.mts +113 -0
  15. package/dist/handlers.d.mts.map +1 -0
  16. package/dist/handlers.mjs +194 -0
  17. package/dist/handlers.mjs.map +1 -0
  18. package/dist/index.d.mts +5 -0
  19. package/dist/index.mjs +345 -0
  20. package/dist/index.mjs.map +1 -0
  21. package/dist/linking-DcQSMgem.mjs +177 -0
  22. package/dist/linking-DcQSMgem.mjs.map +1 -0
  23. package/dist/linking-nKoayyHf.d.mts +133 -0
  24. package/dist/linking-nKoayyHf.d.mts.map +1 -0
  25. package/dist/machine-CRHKjtoP.d.mts +223 -0
  26. package/dist/machine-CRHKjtoP.d.mts.map +1 -0
  27. package/dist/machine-D_5DAFxi.mjs +155 -0
  28. package/dist/machine-D_5DAFxi.mjs.map +1 -0
  29. package/dist/qr.d.mts +58 -0
  30. package/dist/qr.d.mts.map +1 -0
  31. package/dist/qr.mjs +27 -0
  32. package/dist/qr.mjs.map +1 -0
  33. package/dist/scan/index.d.mts +381 -0
  34. package/dist/scan/index.d.mts.map +1 -0
  35. package/dist/scan/index.mjs +409 -0
  36. package/dist/scan/index.mjs.map +1 -0
  37. package/dist/scan/worker.d.mts +2 -0
  38. package/dist/scan/worker.mjs +2 -0
  39. package/dist/server-BhoYRkCm.d.mts +257 -0
  40. package/dist/server-BhoYRkCm.d.mts.map +1 -0
  41. package/dist/stores/kv.d.mts +64 -0
  42. package/dist/stores/kv.d.mts.map +1 -0
  43. package/dist/stores/kv.mjs +87 -0
  44. package/dist/stores/kv.mjs.map +1 -0
  45. package/dist/stores/memory.d.mts +22 -0
  46. package/dist/stores/memory.d.mts.map +1 -0
  47. package/dist/stores/memory.mjs +42 -0
  48. package/dist/stores/memory.mjs.map +1 -0
  49. package/dist/types-BvBIFPH6.mjs +7 -0
  50. package/dist/types-BvBIFPH6.mjs.map +1 -0
  51. package/dist/types-C82lb-zX.d.mts +82 -0
  52. package/dist/types-C82lb-zX.d.mts.map +1 -0
  53. package/dist/worker-BdwaK1uX.mjs +5291 -0
  54. package/dist/worker-BdwaK1uX.mjs.map +1 -0
  55. package/dist/worker-DxbdBA2z.d.mts +164 -0
  56. package/dist/worker-DxbdBA2z.d.mts.map +1 -0
  57. 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