@saeris/hanko 0.0.0 → 0.2.1

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 +28 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +346 -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 +384 -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
@@ -0,0 +1,257 @@
1
+ import { i as GrantState, r as GrantEvent } from "./machine-CRHKjtoP.mjs";
2
+ import { a as DeviceGrantStore, i as DeviceGrant, n as DeviceAuthorizationError, r as DeviceAuthorizationResponse } from "./types-C82lb-zX.mjs";
3
+ //#region src/codes.d.ts
4
+ /**
5
+ * Code generation.
6
+ *
7
+ * Two codes with opposite constraints:
8
+ *
9
+ * - `user_code` is read off a TV across a room and typed on a phone, so it must
10
+ * be SHORT. That caps its entropy, which is why RFC 8628 §5.1 requires the
11
+ * server to rate-limit attempts — the code alone is not brute-force safe.
12
+ * - `device_code` is never displayed, so it has no usability ceiling. The spec
13
+ * says "a very high entropy code SHOULD be used".
14
+ *
15
+ * @see https://datatracker.ietf.org/doc/html/rfc8628#section-5.1
16
+ */
17
+ /**
18
+ * RFC 8628 §6.1's recommended base-20 alphabet: consonants only.
19
+ *
20
+ * No vowels, so generated codes cannot accidentally spell words. No digits, so
21
+ * there is no 0/O or 1/l/I confusion. 20^8 ≈ 34.5 bits at 8 characters, which
22
+ * the spec pairs with a 5-attempt rate limit.
23
+ */
24
+ declare const BASE20_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
25
+ /**
26
+ * Digits only — better for non-Latin locales and numeric TV remotes, which is
27
+ * why Plex-style flows often use them. Lower entropy per character than base-20
28
+ * (10 vs 20), so prefer a longer code when using this.
29
+ */
30
+ declare const NUMERIC_ALPHABET = "0123456789";
31
+ interface UserCodeOptions {
32
+ /** Significant characters, excluding any separator. Spec example uses 8. */
33
+ length?: number;
34
+ /** Character set to draw from. Defaults to {@link BASE20_ALPHABET}. */
35
+ alphabet?: string;
36
+ /**
37
+ * Inserted every `groupSize` characters purely for legibility.
38
+ * The stored/compared form never contains it — see {@link normalizeUserCode}.
39
+ */
40
+ separator?: string;
41
+ /** Characters per visual group. Ignored when `separator` is empty. */
42
+ groupSize?: number;
43
+ }
44
+ /**
45
+ * Generate a user-facing code, formatted for display.
46
+ *
47
+ * Defaults follow the spec's worked example: 8 base-20 characters shown as
48
+ * `WDJB-MJHT`.
49
+ */
50
+ declare const generateUserCode: ({ length, alphabet, separator, groupSize }?: UserCodeOptions) => string;
51
+ /**
52
+ * Canonicalize user input before comparison.
53
+ *
54
+ * RFC 8628 §6.1: the server strips punctuation it added for readability, and
55
+ * uppercases A-Z codes. Without this, a user typing `wdjb mjht` — which is what
56
+ * a phone keyboard will autocorrect toward — fails against `WDJB-MJHT` for no
57
+ * reason the user can see.
58
+ *
59
+ * Strips ALL non-alphanumerics, so it is agnostic to whichever separator the
60
+ * display format used.
61
+ */
62
+ declare const normalizeUserCode: (input: string) => string;
63
+ /**
64
+ * Generate a `device_code`: 256 bits, base64url, no padding.
65
+ *
66
+ * base64url (not base64) because this value travels in URLs and form bodies,
67
+ * where `+` and `/` would need escaping.
68
+ */
69
+ declare const generateDeviceCode: () => string;
70
+ //#endregion
71
+ //#region src/grant.d.ts
72
+ /** Observers of a grant's lifecycle. All optional, all fire after the move. */
73
+ interface GrantHooks {
74
+ /** Any successful transition. */
75
+ onTransition?: (from: GrantState, to: GrantState, grant: DeviceGrant) => void;
76
+ /** The user authorized. `subject` is whoever they are to the host app. */
77
+ onApproved?: (subject: string, grant: DeviceGrant) => void;
78
+ /** The user refused. */
79
+ onDenied?: (grant: DeviceGrant) => void;
80
+ /** The deadline passed without a decision, or before redemption. */
81
+ onExpired?: (grant: DeviceGrant) => void;
82
+ /** The device redeemed its approval. Terminal. */
83
+ onRedeemed?: (subject: string, grant: DeviceGrant) => void;
84
+ /**
85
+ * An event the current state does not accept.
86
+ *
87
+ * Not an error — a device polling twice in a row legitimately produces one —
88
+ * but worth surfacing, since a burst of them means a confused caller.
89
+ */
90
+ onRejected?: (state: GrantState, event: GrantEvent[`type`]) => void;
91
+ }
92
+ declare class Grant {
93
+ #private;
94
+ constructor(grant: DeviceGrant, hooks?: GrantHooks);
95
+ /** Rehydrate from a store record. */
96
+ static from(grant: DeviceGrant, hooks?: GrantHooks): Grant;
97
+ get state(): GrantState;
98
+ get userCode(): string;
99
+ get interval(): number;
100
+ get settled(): boolean;
101
+ /**
102
+ * The approving identity — readable only once approved.
103
+ *
104
+ * Deliberately not a plain field: reading it in any other state is a caller
105
+ * bug, and returning `undefined` silently would let it be handed to a session
106
+ * factory as an empty subject.
107
+ */
108
+ get subject(): string;
109
+ /** Whether this grant's deadline has passed as of `now`. */
110
+ expired(now: number): boolean;
111
+ /**
112
+ * Whether a poll at `now` arrives sooner than the agreed interval.
113
+ *
114
+ * The first poll is always allowed; only a second one inside the window is
115
+ * early. Used by the server to decide between `authorization_pending` and
116
+ * `slow_down`.
117
+ */
118
+ pollingTooSoon(now: number): boolean;
119
+ /** Record that a poll happened, without changing state. */
120
+ markPolled(now: number): void;
121
+ /**
122
+ * Apply `slow_down`: add 5s permanently, per RFC 8628 §3.5.
123
+ *
124
+ * A method rather than a setter — the increment is the spec's, not the
125
+ * caller's, and exposing the interval for assignment would invite an
126
+ * exponential backoff that the spec reserves for connection failures.
127
+ */
128
+ slowDown(now: number): number;
129
+ /**
130
+ * Send an event. Returns whether it moved the grant.
131
+ *
132
+ * The only way to change state. Illegal events are rejected rather than
133
+ * throwing: double-approval and re-redemption are things a real caller does,
134
+ * and they must be no-ops rather than crashes.
135
+ */
136
+ send(event: GrantEvent): boolean;
137
+ /**
138
+ * Plain record for persistence.
139
+ *
140
+ * Named `toJSON` so `JSON.stringify` picks it up — but note it includes
141
+ * `device_code` and `subject`, so it is a store payload, not something to
142
+ * send to a client.
143
+ */
144
+ toJSON(): DeviceGrant;
145
+ }
146
+ //#endregion
147
+ //#region src/server.d.ts
148
+ interface HankoServerOptions {
149
+ /** Where grants live. Use `MemoryDeviceGrantStore` for dev. */
150
+ store: DeviceGrantStore;
151
+ /**
152
+ * Absolute URL the user visits to approve, e.g. `https://example.com/link`.
153
+ * Shown on the device verbatim, so keep it short and typeable.
154
+ */
155
+ verificationUri: string;
156
+ /**
157
+ * Builds the QR target. Defaults to `${verificationUri}?user_code=${code}`.
158
+ * Override if your approval page reads the code from a path segment.
159
+ */
160
+ buildVerificationUriComplete?: (userCode: string, verificationUri: string) => string;
161
+ /** Code lifetime in seconds. Default 900 (15 min). */
162
+ expiresInSeconds?: number;
163
+ /** Starting poll interval in seconds. Spec default 5. */
164
+ intervalSeconds?: number;
165
+ /** Shape of the user code. See {@link UserCodeOptions}. */
166
+ userCode?: UserCodeOptions;
167
+ /** Injectable clock. Tests pass a fake; production leaves it alone. */
168
+ now?: () => number;
169
+ /**
170
+ * Lifecycle observers, applied to every grant this server handles.
171
+ *
172
+ * The integration seam for host frameworks: persist to a second store, emit
173
+ * telemetry, push to a websocket. Hooks observe; they cannot force a
174
+ * transition.
175
+ */
176
+ hooks?: GrantHooks;
177
+ }
178
+ /** Discriminated result of a poll. Callers switch on `status`. */
179
+ type PollResult = {
180
+ status: `pending`;
181
+ error: Extract<DeviceAuthorizationError, `authorization_pending`>;
182
+ } | {
183
+ status: `slow_down`;
184
+ error: Extract<DeviceAuthorizationError, `slow_down`>;
185
+ interval: number;
186
+ } | {
187
+ status: `denied`;
188
+ error: Extract<DeviceAuthorizationError, `access_denied`>;
189
+ } | {
190
+ status: `expired`;
191
+ error: Extract<DeviceAuthorizationError, `expired_token`>;
192
+ } | {
193
+ status: `approved`;
194
+ subject: string;
195
+ };
196
+ interface ApproveResult {
197
+ ok: boolean;
198
+ /** Present when `ok` is false. Lets callers show a precise message. */
199
+ reason?: `not_found` | `expired` | `already_resolved`;
200
+ grant?: DeviceGrant;
201
+ }
202
+ declare class HankoServer {
203
+ #private;
204
+ constructor({ store, verificationUri, buildVerificationUriComplete, expiresInSeconds, intervalSeconds, userCode, now, hooks }: HankoServerOptions);
205
+ /**
206
+ * Start a flow. Call from the device-authorization endpoint.
207
+ *
208
+ * Returns the spec's response shape directly, so a host route can serialize
209
+ * it as-is.
210
+ */
211
+ requestAuthorization({ clientId, scope, verificationUri }?: {
212
+ clientId?: string;
213
+ scope?: string;
214
+ /**
215
+ * Override the configured verification URI for this grant.
216
+ *
217
+ * One deployment is commonly reachable through several hostnames — a
218
+ * preview URL, a custom domain, a tunnel — and the QR has to encode the one
219
+ * the device is actually talking to. Derive it from the incoming request
220
+ * (`x-forwarded-host` behind a proxy) and the code always points somewhere
221
+ * reachable, with no redeploy when the hostname changes.
222
+ */
223
+ verificationUri?: string;
224
+ }): Promise<DeviceAuthorizationResponse>;
225
+ /**
226
+ * Poll for a decision. Call from the token endpoint.
227
+ *
228
+ * Enforces the interval: polling faster than allowed returns `slow_down` and
229
+ * permanently raises this grant's interval by 5s, per §3.5. The increase is
230
+ * additive and sticky — NOT exponential backoff, which the spec reserves for
231
+ * connection timeouts.
232
+ */
233
+ poll(deviceCode: string): Promise<PollResult>;
234
+ /**
235
+ * Look up a grant by the code the user typed or arrived with.
236
+ *
237
+ * The approval page MUST display the returned `user_code` back to the user so
238
+ * they can confirm it matches the screen — RFC 8628 §5.4. That check is the
239
+ * only defense against a phished QR pointing at an attacker's device, so it
240
+ * is not optional UI polish.
241
+ */
242
+ lookupByUserCode(input: string): Promise<DeviceGrant | null>;
243
+ /** Record approval. `subject` is opaque to hanko; it is echoed back on poll. */
244
+ approve(input: string, subject: string): Promise<ApproveResult>;
245
+ /** Record denial, so the device can stop polling and say why. */
246
+ deny(input: string): Promise<ApproveResult>;
247
+ }
248
+ /**
249
+ * Factory kept for ergonomics and backwards compatibility.
250
+ *
251
+ * The class is the real API; this is sugar for callers who prefer not to write
252
+ * `new`, and it keeps existing call sites working.
253
+ */
254
+ declare const createHankoServer: (options: HankoServerOptions) => HankoServer;
255
+ //#endregion
256
+ export { createHankoServer as a, BASE20_ALPHABET as c, generateDeviceCode as d, generateUserCode as f, PollResult as i, NUMERIC_ALPHABET as l, HankoServer as n, Grant as o, normalizeUserCode as p, HankoServerOptions as r, GrantHooks as s, ApproveResult as t, UserCodeOptions as u };
257
+ //# sourceMappingURL=server-BhoYRkCm.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-BhoYRkCm.d.mts","names":[],"sources":["../src/codes.ts","../src/grant.ts","../src/server.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;cAqBa;;;;;;cAOA;UAwBI;;EAEf;;EAEA;;;;;EAKA;;EAEA;;;;;;;;cASW,qBAAoB,QAAA,UAAA,WAAA,cAK9B;;;;;;;;;;;;cA4BU,oBAAqB;;;;;;;cASrB;;;;UC5FI;;EAEf,gBAAgB,MAAM,YAAY,IAAI,YAAY,OAAO;;EAEzD,cAAc,iBAAiB,OAAO;;EAEtC,YAAY,OAAO;;EAEnB,aAAa,OAAO;;EAEpB,cAAc,iBAAiB,OAAO;;;;;;;EAOtC,cAAc,OAAO,YAAY,OAAO;;cAG7B;;EAaC,YAAA,OAAO,aAAa,QAAO;;SAchC,KAAK,OAAO,aAAa,QAAQ,aAAa;MAIjD,SAAS;MAIT;MAIA;MAIA;;;;;;;;MAWA;;EAUJ,QAAQ;;;;;;;;EAWR,eAAe;;EAQf,WAAW;;;;;;;;EAWX,SAAS;;;;;;;;EAaT,KAAK,OAAO;;;;;;;;EA0CZ,UAAU;;;;UC/JK;;EAEf,OAAO;;;;;EAKP;;;;;EAKA,gCACE,kBACA;;EAGF;;EAEA;;EAEA,WAAW;;EAEX;;;;;;;;EAQA,QAAQ;;;KAIE;EAEN;EACA,OAAO,QAAQ;;EAGf;EACA,OAAO,QAAQ;EACf;;EAGA;EACA,OAAO,QAAQ;;EAGf;EACA,OAAO,QAAQ;;EAEf;EAAoB;;UAET;EACf;;EAEA;EACA,QAAQ;;cAGG;;EAaC,cACV,OACA,iBACA,8BAEA,kBACA,iBACA,UACA,KACA,SACC;;;;;;;EAuCG,uBACJ,UACA,OACA;IAEA;IACA;;;;;;;;;;IAUA;MACO,QAAQ;;;;;;;;;EAqCX,KAAK,qBAAqB,QAAQ;;;;;;;;;EA+ClC,iBAAiB,gBAAgB,QAAQ;;EAQzC,QAAQ,eAAe,kBAAkB,QAAQ;;EAKjD,KAAK,gBAAgB,QAAQ;;;;;;;;cA6CxB,oBAAqB,SAAS,uBAAqB"}
@@ -0,0 +1,64 @@
1
+ import { a as DeviceGrantStore, i as DeviceGrant } from "../types-C82lb-zX.mjs";
2
+ //#region src/stores/kv.d.ts
3
+ /** What hanko needs from a KV service. */
4
+ interface KeyValueAdapter {
5
+ get(key: string): Promise<string | null>;
6
+ /** `ttlSeconds` is a hint; stores without TTL may ignore it (see below). */
7
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
8
+ delete(key: string): Promise<void>;
9
+ }
10
+ interface KvDeviceGrantStoreOptions {
11
+ kv: KeyValueAdapter;
12
+ /** Namespace, so grants cannot collide with the host app's own keys. */
13
+ prefix?: string;
14
+ /**
15
+ * Extra seconds to keep a grant past its own deadline.
16
+ *
17
+ * Without this, a grant vanishes at the instant it expires and the device's
18
+ * next poll gets "unknown code" — indistinguishable from a typo. The grace
19
+ * window lets the server answer `expired_token` honestly, which is what a
20
+ * client needs to stop cleanly rather than retry.
21
+ */
22
+ graceSeconds?: number;
23
+ now?: () => number;
24
+ }
25
+ /**
26
+ * Two keys per grant: `device:<code>` holds the record, `user:<code>` points at
27
+ * it.
28
+ *
29
+ * A pointer rather than a duplicate, because the record changes on every poll
30
+ * and two copies would diverge — the device would see a raised interval the
31
+ * approval page did not.
32
+ */
33
+ declare class KvDeviceGrantStore implements DeviceGrantStore {
34
+ #private;
35
+ constructor({ kv, prefix, graceSeconds, now }: KvDeviceGrantStoreOptions);
36
+ create(grant: DeviceGrant): Promise<void>;
37
+ findByDeviceCode(deviceCode: string): Promise<DeviceGrant | null>;
38
+ findByUserCode(userCode: string): Promise<DeviceGrant | null>;
39
+ update(grant: DeviceGrant): Promise<void>;
40
+ /**
41
+ * No-op: TTL is the pruning mechanism.
42
+ *
43
+ * Present so the interface is satisfied without a scan. Sweeping a KV store
44
+ * for expired keys would cost a list operation per request to do worse than
45
+ * what the service already does for free.
46
+ */
47
+ prune(): void;
48
+ }
49
+ /**
50
+ * Adapter for a KV service whose `set` takes options rather than a TTL number.
51
+ *
52
+ * Covers Workers KV (`{ expirationTtl }`) and Vercel KV (`{ ex }`) without
53
+ * either needing its own class.
54
+ */
55
+ declare const kvFromOptionsApi: ({ get, set, remove, ttlKey }: {
56
+ get: (key: string) => Promise<string | null>;
57
+ set: (key: string, value: string, options: Record<string, number>) => Promise<void>;
58
+ remove: (key: string) => Promise<void>;
59
+ /** Option name carrying the TTL. `expirationTtl` for Workers KV, `ex` for Redis. */
60
+ ttlKey?: string;
61
+ }) => KeyValueAdapter;
62
+ //#endregion
63
+ export { KeyValueAdapter, KvDeviceGrantStore, KvDeviceGrantStoreOptions, kvFromOptionsApi };
64
+ //# sourceMappingURL=kv.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kv.d.mts","names":[],"sources":["../../src/stores/kv.ts"],"mappings":";;;UAgBiB;EACf,IAAI,cAAc;;EAElB,IAAI,aAAa,eAAe,qBAAqB;EACrD,OAAO,cAAc;;UAGN;EACf,IAAI;;EAEJ;;;;;;;;;EASA;EACA;;;;;;;;;;cAWW,8BAA8B;;EAM7B,cACV,IACA,QACA,cACA,OACC;EAqBG,OAAO,OAAO,cAAc;EAU5B,iBAAiB,qBAAqB,QAAQ;EAiB9C,eAAe,mBAAmB,QAAQ;EAK1C,OAAO,OAAO,cAAc;;;;;;;;EAqBlC;;;;;;;;cAWW,qBAAoB,KAAA,KAAA,QAAA;EAM/B,MAAM,gBAAgB;EACtB,MACE,aACA,eACA,SAAS,2BACN;EACL,SAAS,gBAAgB;;EAEzB;MACE"}
@@ -0,0 +1,87 @@
1
+ //#region src/stores/kv.ts
2
+ /**
3
+ * Two keys per grant: `device:<code>` holds the record, `user:<code>` points at
4
+ * it.
5
+ *
6
+ * A pointer rather than a duplicate, because the record changes on every poll
7
+ * and two copies would diverge — the device would see a raised interval the
8
+ * approval page did not.
9
+ */
10
+ var KvDeviceGrantStore = class {
11
+ #kv;
12
+ #prefix;
13
+ #graceSeconds;
14
+ #now;
15
+ constructor({ kv, prefix = `hanko`, graceSeconds = 60, now = () => Date.now() }) {
16
+ this.#kv = kv;
17
+ this.#prefix = prefix;
18
+ this.#graceSeconds = graceSeconds;
19
+ this.#now = now;
20
+ }
21
+ #deviceKey(code) {
22
+ return `${this.#prefix}:device:${code}`;
23
+ }
24
+ #userKey(code) {
25
+ return `${this.#prefix}:user:${code}`;
26
+ }
27
+ /** Seconds until this grant may be dropped. At least 1 — never 0 or negative. */
28
+ #ttl(grant) {
29
+ const remaining = Math.ceil((grant.expiresAt - this.#now()) / 1e3);
30
+ return Math.max(1, remaining + this.#graceSeconds);
31
+ }
32
+ async create(grant) {
33
+ const ttl = this.#ttl(grant);
34
+ await this.#kv.set(this.#deviceKey(grant.device_code), JSON.stringify(grant), ttl);
35
+ await this.#kv.set(this.#userKey(grant.user_code), grant.device_code, ttl);
36
+ }
37
+ async findByDeviceCode(deviceCode) {
38
+ const raw = await this.#kv.get(this.#deviceKey(deviceCode));
39
+ if (raw === null) return null;
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ return isDeviceGrant(parsed) ? parsed : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ async findByUserCode(userCode) {
48
+ const deviceCode = await this.#kv.get(this.#userKey(userCode));
49
+ return deviceCode === null ? null : this.findByDeviceCode(deviceCode);
50
+ }
51
+ async update(grant) {
52
+ await this.#kv.set(this.#deviceKey(grant.device_code), JSON.stringify(grant), this.#ttl(grant));
53
+ }
54
+ /**
55
+ * No-op: TTL is the pruning mechanism.
56
+ *
57
+ * Present so the interface is satisfied without a scan. Sweeping a KV store
58
+ * for expired keys would cost a list operation per request to do worse than
59
+ * what the service already does for free.
60
+ */
61
+ prune() {}
62
+ };
63
+ /**
64
+ * Adapter for a KV service whose `set` takes options rather than a TTL number.
65
+ *
66
+ * Covers Workers KV (`{ expirationTtl }`) and Vercel KV (`{ ex }`) without
67
+ * either needing its own class.
68
+ */
69
+ const kvFromOptionsApi = ({ get, set, remove, ttlKey = `expirationTtl` }) => ({
70
+ get,
71
+ set: async (key, value, ttlSeconds) => {
72
+ await set(key, value, { [ttlKey]: ttlSeconds });
73
+ },
74
+ delete: remove
75
+ });
76
+ /**
77
+ * Structural check for a stored grant.
78
+ *
79
+ * Only the fields the machine cannot run without. Optional ones are left
80
+ * unchecked — a missing `scope` costs a label on the approval screen, while a
81
+ * missing `status` would put the state machine in an undefined state.
82
+ */
83
+ const isDeviceGrant = (value) => typeof value === `object` && value !== null && `device_code` in value && typeof value.device_code === `string` && `user_code` in value && typeof value.user_code === `string` && `status` in value && typeof value.status === `string` && `expiresAt` in value && typeof value.expiresAt === `number` && `interval` in value && typeof value.interval === `number`;
84
+ //#endregion
85
+ export { KvDeviceGrantStore, kvFromOptionsApi };
86
+
87
+ //# sourceMappingURL=kv.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kv.mjs","names":["#kv","#prefix","#graceSeconds","#now","#ttl","#deviceKey","#userKey"],"sources":["../../src/stores/kv.ts"],"sourcesContent":["/**\n * Grant store over any TTL-capable key-value service.\n *\n * The shape Upstash Redis, Cloudflare Workers KV, Deno KV, Vercel KV, and\n * plain `ioredis` all satisfy — three methods, one of which takes a TTL. That\n * is deliberately the lowest common denominator, so an adapter is a few lines\n * rather than a package.\n *\n * Suits stateless edge functions specifically: nothing is cached in the\n * instance, so a flow survives its requests landing on different workers, and\n * TTL expiry means abandoned grants cost nothing to clean up.\n */\n\nimport type { DeviceGrant, DeviceGrantStore } from \"../types.js\";\n\n/** What hanko needs from a KV service. */\nexport interface KeyValueAdapter {\n get(key: string): Promise<string | null>;\n /** `ttlSeconds` is a hint; stores without TTL may ignore it (see below). */\n set(key: string, value: string, ttlSeconds: number): Promise<void>;\n delete(key: string): Promise<void>;\n}\n\nexport interface KvDeviceGrantStoreOptions {\n kv: KeyValueAdapter;\n /** Namespace, so grants cannot collide with the host app's own keys. */\n prefix?: string;\n /**\n * Extra seconds to keep a grant past its own deadline.\n *\n * Without this, a grant vanishes at the instant it expires and the device's\n * next poll gets \"unknown code\" — indistinguishable from a typo. The grace\n * window lets the server answer `expired_token` honestly, which is what a\n * client needs to stop cleanly rather than retry.\n */\n graceSeconds?: number;\n now?: () => number;\n}\n\n/**\n * Two keys per grant: `device:<code>` holds the record, `user:<code>` points at\n * it.\n *\n * A pointer rather than a duplicate, because the record changes on every poll\n * and two copies would diverge — the device would see a raised interval the\n * approval page did not.\n */\nexport class KvDeviceGrantStore implements DeviceGrantStore {\n readonly #kv: KeyValueAdapter;\n readonly #prefix: string;\n readonly #graceSeconds: number;\n readonly #now: () => number;\n\n constructor({\n kv,\n prefix = `hanko`,\n graceSeconds = 60,\n now = (): number => Date.now()\n }: KvDeviceGrantStoreOptions) {\n this.#kv = kv;\n this.#prefix = prefix;\n this.#graceSeconds = graceSeconds;\n this.#now = now;\n }\n\n #deviceKey(code: string): string {\n return `${this.#prefix}:device:${code}`;\n }\n\n #userKey(code: string): string {\n return `${this.#prefix}:user:${code}`;\n }\n\n /** Seconds until this grant may be dropped. At least 1 — never 0 or negative. */\n #ttl(grant: DeviceGrant): number {\n const remaining = Math.ceil((grant.expiresAt - this.#now()) / 1000);\n return Math.max(1, remaining + this.#graceSeconds);\n }\n\n async create(grant: DeviceGrant): Promise<void> {\n const ttl = this.#ttl(grant);\n await this.#kv.set(\n this.#deviceKey(grant.device_code),\n JSON.stringify(grant),\n ttl\n );\n await this.#kv.set(this.#userKey(grant.user_code), grant.device_code, ttl);\n }\n\n async findByDeviceCode(deviceCode: string): Promise<DeviceGrant | null> {\n const raw = await this.#kv.get(this.#deviceKey(deviceCode));\n if (raw === null) return null;\n try {\n const parsed: unknown = JSON.parse(raw);\n // Validated rather than asserted: this value came back from a shared\n // store that another deploy — or an older version of this library — may\n // have written. A half-shaped record would fail deep inside the machine\n // instead of here.\n return isDeviceGrant(parsed) ? parsed : null;\n } catch {\n // A corrupt value is not a live grant. Returning null lets the caller\n // report `expired_token` rather than crashing an edge function.\n return null;\n }\n }\n\n async findByUserCode(userCode: string): Promise<DeviceGrant | null> {\n const deviceCode = await this.#kv.get(this.#userKey(userCode));\n return deviceCode === null ? null : this.findByDeviceCode(deviceCode);\n }\n\n async update(grant: DeviceGrant): Promise<void> {\n // Rewrites the TTL on every update, which keeps the key alive exactly as\n // long as the grant's own deadline says — not as long as the last write\n // happened to leave it.\n await this.#kv.set(\n this.#deviceKey(grant.device_code),\n JSON.stringify(grant),\n this.#ttl(grant)\n );\n }\n\n /**\n * No-op: TTL is the pruning mechanism.\n *\n * Present so the interface is satisfied without a scan. Sweeping a KV store\n * for expired keys would cost a list operation per request to do worse than\n * what the service already does for free.\n */\n // Must stay an instance method to satisfy `DeviceGrantStore`; making it\n // static would take it off the interface.\n // oxlint-disable-next-line eslint/class-methods-use-this\n prune(): void {\n // Intentionally empty.\n }\n}\n\n/**\n * Adapter for a KV service whose `set` takes options rather than a TTL number.\n *\n * Covers Workers KV (`{ expirationTtl }`) and Vercel KV (`{ ex }`) without\n * either needing its own class.\n */\nexport const kvFromOptionsApi = ({\n get,\n set,\n remove,\n ttlKey = `expirationTtl`\n}: {\n get: (key: string) => Promise<string | null>;\n set: (\n key: string,\n value: string,\n options: Record<string, number>\n ) => Promise<void>;\n remove: (key: string) => Promise<void>;\n /** Option name carrying the TTL. `expirationTtl` for Workers KV, `ex` for Redis. */\n ttlKey?: string;\n}): KeyValueAdapter => ({\n get,\n set: async (key, value, ttlSeconds) => {\n await set(key, value, { [ttlKey]: ttlSeconds });\n },\n delete: remove\n});\n\n/**\n * Structural check for a stored grant.\n *\n * Only the fields the machine cannot run without. Optional ones are left\n * unchecked — a missing `scope` costs a label on the approval screen, while a\n * missing `status` would put the state machine in an undefined state.\n */\nconst isDeviceGrant = (value: unknown): value is DeviceGrant =>\n typeof value === `object` &&\n value !== null &&\n `device_code` in value &&\n typeof value.device_code === `string` &&\n `user_code` in value &&\n typeof value.user_code === `string` &&\n `status` in value &&\n typeof value.status === `string` &&\n `expiresAt` in value &&\n typeof value.expiresAt === `number` &&\n `interval` in value &&\n typeof value.interval === `number`;\n"],"mappings":";;;;;;;;;AA+CA,IAAa,qBAAb,MAA4D;CAC1D;CACA;CACA;CACA;CAEA,YAAY,EACV,IACA,SAAS,SACT,eAAe,IACf,YAAoB,KAAK,IAAI,KACD;EAC5B,KAAKA,MAAM;EACX,KAAKC,UAAU;EACf,KAAKC,gBAAgB;EACrB,KAAKC,OAAO;CACd;CAEA,WAAW,MAAsB;EAC/B,OAAO,GAAG,KAAKF,QAAQ,UAAU;CACnC;CAEA,SAAS,MAAsB;EAC7B,OAAO,GAAG,KAAKA,QAAQ,QAAQ;CACjC;;CAGA,KAAK,OAA4B;EAC/B,MAAM,YAAY,KAAK,MAAM,MAAM,YAAY,KAAKE,KAAK,KAAK,GAAI;EAClE,OAAO,KAAK,IAAI,GAAG,YAAY,KAAKD,aAAa;CACnD;CAEA,MAAM,OAAO,OAAmC;EAC9C,MAAM,MAAM,KAAKE,KAAK,KAAK;EAC3B,MAAM,KAAKJ,IAAI,IACb,KAAKK,WAAW,MAAM,WAAW,GACjC,KAAK,UAAU,KAAK,GACpB,GACF;EACA,MAAM,KAAKL,IAAI,IAAI,KAAKM,SAAS,MAAM,SAAS,GAAG,MAAM,aAAa,GAAG;CAC3E;CAEA,MAAM,iBAAiB,YAAiD;EACtE,MAAM,MAAM,MAAM,KAAKN,IAAI,IAAI,KAAKK,WAAW,UAAU,CAAC;EAC1D,IAAI,QAAQ,MAAM,OAAO;EACzB,IAAI;GACF,MAAM,SAAkB,KAAK,MAAM,GAAG;GAKtC,OAAO,cAAc,MAAM,IAAI,SAAS;EAC1C,QAAQ;GAGN,OAAO;EACT;CACF;CAEA,MAAM,eAAe,UAA+C;EAClE,MAAM,aAAa,MAAM,KAAKL,IAAI,IAAI,KAAKM,SAAS,QAAQ,CAAC;EAC7D,OAAO,eAAe,OAAO,OAAO,KAAK,iBAAiB,UAAU;CACtE;CAEA,MAAM,OAAO,OAAmC;EAI9C,MAAM,KAAKN,IAAI,IACb,KAAKK,WAAW,MAAM,WAAW,GACjC,KAAK,UAAU,KAAK,GACpB,KAAKD,KAAK,KAAK,CACjB;CACF;;;;;;;;CAYA,QAAc,CAEd;AACF;;;;;;;AAQA,MAAa,oBAAoB,EAC/B,KACA,KACA,QACA,SAAS,uBAWa;CACtB;CACA,KAAK,OAAO,KAAK,OAAO,eAAe;EACrC,MAAM,IAAI,KAAK,OAAO,GAAG,SAAS,WAAW,CAAC;CAChD;CACA,QAAQ;AACV;;;;;;;;AASA,MAAM,iBAAiB,UACrB,OAAO,UAAU,YACjB,UAAU,QACV,iBAAiB,SACjB,OAAO,MAAM,gBAAgB,YAC7B,eAAe,SACf,OAAO,MAAM,cAAc,YAC3B,YAAY,SACZ,OAAO,MAAM,WAAW,YACxB,eAAe,SACf,OAAO,MAAM,cAAc,YAC3B,cAAc,SACd,OAAO,MAAM,aAAa"}
@@ -0,0 +1,22 @@
1
+ import { a as DeviceGrantStore, i as DeviceGrant } from "../types-C82lb-zX.mjs";
2
+ //#region src/stores/memory.d.ts
3
+ declare class MemoryDeviceGrantStore implements DeviceGrantStore {
4
+ #private;
5
+ create(grant: DeviceGrant): void;
6
+ findByDeviceCode(deviceCode: string): DeviceGrant | null;
7
+ findByUserCode(userCode: string): DeviceGrant | null;
8
+ update(grant: DeviceGrant): void;
9
+ /**
10
+ * Drop grants past their deadline.
11
+ *
12
+ * Without this the maps grow without bound in a long-lived dev server. Both
13
+ * indexes must be cleared together or the user_code index leaks entries
14
+ * pointing at deleted grants.
15
+ */
16
+ prune(now: number): void;
17
+ /** Test affordance: current grant count. */
18
+ get size(): number;
19
+ }
20
+ //#endregion
21
+ export { MemoryDeviceGrantStore };
22
+ //# sourceMappingURL=memory.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.mts","names":[],"sources":["../../src/stores/memory.ts"],"mappings":";;cAWa,kCAAkC;;EAM7C,OAAO,OAAO;EAKd,iBAAiB,qBAAqB;EAItC,eAAe,mBAAmB;EAKlC,OAAO,OAAO;;;;;;;;EAWd,MAAM;;MAUF"}
@@ -0,0 +1,42 @@
1
+ //#region src/stores/memory.ts
2
+ var MemoryDeviceGrantStore = class {
3
+ /** Keyed by device_code. */
4
+ #byDeviceCode = /* @__PURE__ */ new Map();
5
+ /** user_code → device_code. Avoids scanning on the user-facing lookup. */
6
+ #userCodeIndex = /* @__PURE__ */ new Map();
7
+ create(grant) {
8
+ this.#byDeviceCode.set(grant.device_code, grant);
9
+ this.#userCodeIndex.set(grant.user_code, grant.device_code);
10
+ }
11
+ findByDeviceCode(deviceCode) {
12
+ return this.#byDeviceCode.get(deviceCode) ?? null;
13
+ }
14
+ findByUserCode(userCode) {
15
+ const deviceCode = this.#userCodeIndex.get(userCode);
16
+ return deviceCode === void 0 ? null : this.findByDeviceCode(deviceCode);
17
+ }
18
+ update(grant) {
19
+ this.#byDeviceCode.set(grant.device_code, grant);
20
+ }
21
+ /**
22
+ * Drop grants past their deadline.
23
+ *
24
+ * Without this the maps grow without bound in a long-lived dev server. Both
25
+ * indexes must be cleared together or the user_code index leaks entries
26
+ * pointing at deleted grants.
27
+ */
28
+ prune(now) {
29
+ for (const [deviceCode, grant] of this.#byDeviceCode) if (now >= grant.expiresAt) {
30
+ this.#byDeviceCode.delete(deviceCode);
31
+ this.#userCodeIndex.delete(grant.user_code);
32
+ }
33
+ }
34
+ /** Test affordance: current grant count. */
35
+ get size() {
36
+ return this.#byDeviceCode.size;
37
+ }
38
+ };
39
+ //#endregion
40
+ export { MemoryDeviceGrantStore };
41
+
42
+ //# sourceMappingURL=memory.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.mjs","names":["#byDeviceCode","#userCodeIndex"],"sources":["../../src/stores/memory.ts"],"sourcesContent":["/**\n * In-memory grant store — development and tests only.\n *\n * State dies with the process and is not shared across instances, so this is\n * wrong for any deployment with more than one worker. It exists so the library\n * is runnable with zero infrastructure; real deployments supply a Redis /\n * Postgres / Durable Object adapter against the same interface.\n */\n\nimport type { DeviceGrant, DeviceGrantStore } from \"../types.js\";\n\nexport class MemoryDeviceGrantStore implements DeviceGrantStore {\n /** Keyed by device_code. */\n readonly #byDeviceCode = new Map<string, DeviceGrant>();\n /** user_code → device_code. Avoids scanning on the user-facing lookup. */\n readonly #userCodeIndex = new Map<string, string>();\n\n create(grant: DeviceGrant): void {\n this.#byDeviceCode.set(grant.device_code, grant);\n this.#userCodeIndex.set(grant.user_code, grant.device_code);\n }\n\n findByDeviceCode(deviceCode: string): DeviceGrant | null {\n return this.#byDeviceCode.get(deviceCode) ?? null;\n }\n\n findByUserCode(userCode: string): DeviceGrant | null {\n const deviceCode = this.#userCodeIndex.get(userCode);\n return deviceCode === undefined ? null : this.findByDeviceCode(deviceCode);\n }\n\n update(grant: DeviceGrant): void {\n this.#byDeviceCode.set(grant.device_code, grant);\n }\n\n /**\n * Drop grants past their deadline.\n *\n * Without this the maps grow without bound in a long-lived dev server. Both\n * indexes must be cleared together or the user_code index leaks entries\n * pointing at deleted grants.\n */\n prune(now: number): void {\n for (const [deviceCode, grant] of this.#byDeviceCode) {\n if (now >= grant.expiresAt) {\n this.#byDeviceCode.delete(deviceCode);\n this.#userCodeIndex.delete(grant.user_code);\n }\n }\n }\n\n /** Test affordance: current grant count. */\n get size(): number {\n return this.#byDeviceCode.size;\n }\n}\n"],"mappings":";AAWA,IAAa,yBAAb,MAAgE;;CAE9D,gCAAyB,IAAI,IAAyB;;CAEtD,iCAA0B,IAAI,IAAoB;CAElD,OAAO,OAA0B;EAC/B,KAAKA,cAAc,IAAI,MAAM,aAAa,KAAK;EAC/C,KAAKC,eAAe,IAAI,MAAM,WAAW,MAAM,WAAW;CAC5D;CAEA,iBAAiB,YAAwC;EACvD,OAAO,KAAKD,cAAc,IAAI,UAAU,KAAK;CAC/C;CAEA,eAAe,UAAsC;EACnD,MAAM,aAAa,KAAKC,eAAe,IAAI,QAAQ;EACnD,OAAO,eAAe,KAAA,IAAY,OAAO,KAAK,iBAAiB,UAAU;CAC3E;CAEA,OAAO,OAA0B;EAC/B,KAAKD,cAAc,IAAI,MAAM,aAAa,KAAK;CACjD;;;;;;;;CASA,MAAM,KAAmB;EACvB,KAAK,MAAM,CAAC,YAAY,UAAU,KAAKA,eACrC,IAAI,OAAO,MAAM,WAAW;GAC1B,KAAKA,cAAc,OAAO,UAAU;GACpC,KAAKC,eAAe,OAAO,MAAM,SAAS;EAC5C;CAEJ;;CAGA,IAAI,OAAe;EACjB,OAAO,KAAKD,cAAc;CAC5B;AACF"}
@@ -0,0 +1,7 @@
1
+ //#region src/types.ts
2
+ /** The grant type URN. Sent verbatim as `grant_type`. */
3
+ const DEVICE_CODE_GRANT_TYPE = `urn:ietf:params:oauth:grant-type:device_code`;
4
+ //#endregion
5
+ export { DEVICE_CODE_GRANT_TYPE as t };
6
+
7
+ //# sourceMappingURL=types-BvBIFPH6.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-BvBIFPH6.mjs","names":[],"sources":["../src/types.ts"],"sourcesContent":["/**\n * Wire types for the OAuth 2.0 Device Authorization Grant (RFC 8628).\n *\n * Field names are the spec's, not ours — they cross the network to clients we\n * do not control, so they are forever-identifiers. Do not rename them to fit\n * local style.\n *\n * @see https://datatracker.ietf.org/doc/html/rfc8628\n */\n\nimport type { GrantState } from \"./machine.js\";\n\n/** RFC 8628 §3.2 — device authorization response. */\nexport interface DeviceAuthorizationResponse {\n /** High-entropy secret the device polls with. Never shown to the user. */\n device_code: string;\n /** Short code the user reads off the screen and types (or verifies). */\n user_code: string;\n /** Where the user goes to authorize. */\n verification_uri: string;\n /**\n * `verification_uri` with the `user_code` embedded, for QR/NFC.\n * OPTIONAL in the spec; we always emit it because the QR path is the point.\n */\n verification_uri_complete?: string;\n /** Lifetime in seconds of BOTH codes. */\n expires_in: number;\n /** Minimum seconds between polls. Spec default is 5. */\n interval: number;\n}\n\n/** RFC 8628 §3.5 — token endpoint error codes for this grant. */\nexport type DeviceAuthorizationError =\n /** Keep polling; the user has not finished yet. */\n | `authorization_pending`\n /** Keep polling, but permanently add 5s to the interval. */\n | `slow_down`\n /** Stop. The user said no. */\n | `access_denied`\n /** Stop. The codes aged out. */\n | `expired_token`;\n\n/** The grant type URN. Sent verbatim as `grant_type`. */\nexport const DEVICE_CODE_GRANT_TYPE =\n `urn:ietf:params:oauth:grant-type:device_code` as const;\n\n/**\n * Lifecycle of one authorization attempt.\n *\n * Aliased from `machine.ts`, which owns the states and the legal transitions\n * between them. Kept under its record-field name so a store adapter can type\n * its status column without importing the machine.\n */\nexport type GrantStatus = GrantState;\n\n/**\n * A stored authorization attempt.\n *\n * `subject` is whatever the host app uses to identify the approving user (a\n * DID, a user id, a session id). hanko never interprets it — it only carries it\n * from the approving device back to the polling device.\n */\nexport interface DeviceGrant {\n device_code: string;\n user_code: string;\n status: GrantStatus;\n /** Epoch ms. Compared against an injected clock, never `Date.now()` directly. */\n expiresAt: number;\n /** Seconds. Mutable: `slow_down` raises it. */\n interval: number;\n /** OAuth client that started the flow, if the host app tracks clients. */\n clientId?: string;\n /** Requested scopes, uninterpreted. */\n scope?: string;\n /** Set when status becomes `approved`. */\n subject?: string;\n /** Epoch ms of the last poll, for `slow_down` enforcement. */\n lastPolledAt?: number;\n}\n\n/**\n * Persistence boundary.\n *\n * Deliberately tiny so adapters (Redis, Supabase, Better-Auth, Durable Objects)\n * are trivial. All methods may be async. Lookup by BOTH codes is required: the\n * device polls by `device_code`, the user approves by `user_code`.\n */\nexport interface DeviceGrantStore {\n create(grant: DeviceGrant): Promise<void> | void;\n findByDeviceCode(\n deviceCode: string\n ): Promise<DeviceGrant | null> | DeviceGrant | null;\n findByUserCode(\n userCode: string\n ): Promise<DeviceGrant | null> | DeviceGrant | null;\n update(grant: DeviceGrant): Promise<void> | void;\n /** Drop expired grants. Called opportunistically; may be a no-op with TTL stores. */\n prune?(now: number): Promise<void> | void;\n}\n"],"mappings":";;AA2CA,MAAa,yBACX"}
@@ -0,0 +1,82 @@
1
+ import { i as GrantState } from "./machine-CRHKjtoP.mjs";
2
+ //#region src/types.d.ts
3
+ /** RFC 8628 §3.2 — device authorization response. */
4
+ interface DeviceAuthorizationResponse {
5
+ /** High-entropy secret the device polls with. Never shown to the user. */
6
+ device_code: string;
7
+ /** Short code the user reads off the screen and types (or verifies). */
8
+ user_code: string;
9
+ /** Where the user goes to authorize. */
10
+ verification_uri: string;
11
+ /**
12
+ * `verification_uri` with the `user_code` embedded, for QR/NFC.
13
+ * OPTIONAL in the spec; we always emit it because the QR path is the point.
14
+ */
15
+ verification_uri_complete?: string;
16
+ /** Lifetime in seconds of BOTH codes. */
17
+ expires_in: number;
18
+ /** Minimum seconds between polls. Spec default is 5. */
19
+ interval: number;
20
+ }
21
+ /** RFC 8628 §3.5 — token endpoint error codes for this grant. */
22
+ type DeviceAuthorizationError =
23
+ /** Keep polling; the user has not finished yet. */
24
+ `authorization_pending` |
25
+ /** Keep polling, but permanently add 5s to the interval. */
26
+ `slow_down` |
27
+ /** Stop. The user said no. */
28
+ `access_denied` |
29
+ /** Stop. The codes aged out. */
30
+ `expired_token`;
31
+ /** The grant type URN. Sent verbatim as `grant_type`. */
32
+ declare const DEVICE_CODE_GRANT_TYPE: "urn:ietf:params:oauth:grant-type:device_code";
33
+ /**
34
+ * Lifecycle of one authorization attempt.
35
+ *
36
+ * Aliased from `machine.ts`, which owns the states and the legal transitions
37
+ * between them. Kept under its record-field name so a store adapter can type
38
+ * its status column without importing the machine.
39
+ */
40
+ type GrantStatus = GrantState;
41
+ /**
42
+ * A stored authorization attempt.
43
+ *
44
+ * `subject` is whatever the host app uses to identify the approving user (a
45
+ * DID, a user id, a session id). hanko never interprets it — it only carries it
46
+ * from the approving device back to the polling device.
47
+ */
48
+ interface DeviceGrant {
49
+ device_code: string;
50
+ user_code: string;
51
+ status: GrantStatus;
52
+ /** Epoch ms. Compared against an injected clock, never `Date.now()` directly. */
53
+ expiresAt: number;
54
+ /** Seconds. Mutable: `slow_down` raises it. */
55
+ interval: number;
56
+ /** OAuth client that started the flow, if the host app tracks clients. */
57
+ clientId?: string;
58
+ /** Requested scopes, uninterpreted. */
59
+ scope?: string;
60
+ /** Set when status becomes `approved`. */
61
+ subject?: string;
62
+ /** Epoch ms of the last poll, for `slow_down` enforcement. */
63
+ lastPolledAt?: number;
64
+ }
65
+ /**
66
+ * Persistence boundary.
67
+ *
68
+ * Deliberately tiny so adapters (Redis, Supabase, Better-Auth, Durable Objects)
69
+ * are trivial. All methods may be async. Lookup by BOTH codes is required: the
70
+ * device polls by `device_code`, the user approves by `user_code`.
71
+ */
72
+ interface DeviceGrantStore {
73
+ create(grant: DeviceGrant): Promise<void> | void;
74
+ findByDeviceCode(deviceCode: string): Promise<DeviceGrant | null> | DeviceGrant | null;
75
+ findByUserCode(userCode: string): Promise<DeviceGrant | null> | DeviceGrant | null;
76
+ update(grant: DeviceGrant): Promise<void> | void;
77
+ /** Drop expired grants. Called opportunistically; may be a no-op with TTL stores. */
78
+ prune?(now: number): Promise<void> | void;
79
+ }
80
+ //#endregion
81
+ export { DeviceGrantStore as a, DeviceGrant as i, DeviceAuthorizationError as n, DeviceAuthorizationResponse as r, DEVICE_CODE_GRANT_TYPE as t };
82
+ //# sourceMappingURL=types-C82lb-zX.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-C82lb-zX.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;;UAaiB;;EAEf;;EAEA;;EAEA;;;;;EAKA;;EAEA;;EAEA;;;KAIU;;;;;;;;;;cAWC;;;;;;;;KAUD,cAAc;;;;;;;;UAST;EACf;EACA;EACA,QAAQ;;EAER;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;;UAUe;EACf,OAAO,OAAO,cAAc;EAC5B,iBACE,qBACC,QAAQ,sBAAsB;EACjC,eACE,mBACC,QAAQ,sBAAsB;EACjC,OAAO,OAAO,cAAc;;EAE5B,OAAO,cAAc"}