@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.
- package/CHANGELOG.md +28 -0
- package/LICENSE.md +21 -0
- package/README.md +346 -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 +384 -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
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { a as normalizeUserCode } from "../codes-Ba_qYH6u.mjs";
|
|
2
|
+
import { l as isApprovalSettled, r as approvalTransition } from "../machine-D_5DAFxi.mjs";
|
|
3
|
+
import { a as digitalAssetLinks, c as pwaLaunchHandler, i as consumeLaunchTarget, n as buildAppSchemeUrl, o as expoLinkingConfig, r as buildApprovalUrl, s as parseApprovalLink, t as appleAppSiteAssociation } from "../linking-DcQSMgem.mjs";
|
|
4
|
+
//#region src/scanner.ts
|
|
5
|
+
/** Whether this runtime has a native `BarcodeDetector`. */
|
|
6
|
+
const hasNativeBarcodeDetector = () => typeof globalThis === `object` && `BarcodeDetector` in globalThis;
|
|
7
|
+
/**
|
|
8
|
+
* Scanner backed by the platform's `BarcodeDetector`.
|
|
9
|
+
*
|
|
10
|
+
* Pass the ponyfill's constructor where the native one is missing:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { BarcodeDetector } from "barcode-detector/ponyfill";
|
|
14
|
+
* const scanner = createBarcodeDetectorScanner({ detector: BarcodeDetector });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Restricted to `qr_code`: a taplist beer can carries an EAN-13 barcode, and a
|
|
18
|
+
* scanner that reported it here would send a product code to the approval
|
|
19
|
+
* endpoint as though it were a user code.
|
|
20
|
+
*/
|
|
21
|
+
const createBarcodeDetectorScanner = ({ detector } = {}) => {
|
|
22
|
+
const fromGlobal = globalThis.BarcodeDetector;
|
|
23
|
+
const Ctor = detector ?? (typeof fromGlobal === `function` ? fromGlobal : void 0);
|
|
24
|
+
if (!Ctor) throw new Error(`No BarcodeDetector available. Install "barcode-detector" and pass its ponyfill constructor.`);
|
|
25
|
+
const instance = new Ctor({ formats: [`qr_code`] });
|
|
26
|
+
return { detect: async (source) => instance.detect(source) };
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Read a `user_code` out of a scanned string.
|
|
30
|
+
*
|
|
31
|
+
* QRs in this flow carry `verification_uri_complete` — a URL with the code in
|
|
32
|
+
* it — but a scanner may also see a bare code from a hand-typed fallback, so
|
|
33
|
+
* both are accepted.
|
|
34
|
+
*
|
|
35
|
+
* Returns `null` rather than throwing: a camera pointed at the world sees
|
|
36
|
+
* unrelated codes constantly, and each one is a normal non-event, not an
|
|
37
|
+
* error.
|
|
38
|
+
*/
|
|
39
|
+
const parseScannedCode = (raw, { param = `user_code` } = {}) => {
|
|
40
|
+
const trimmed = raw.trim();
|
|
41
|
+
if (trimmed.length === 0) return null;
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(trimmed);
|
|
44
|
+
if (url.protocol !== `http:` && url.protocol !== `https:`) return null;
|
|
45
|
+
const fromQuery = url.searchParams.get(param);
|
|
46
|
+
if (fromQuery !== null && fromQuery.length > 0) return {
|
|
47
|
+
userCode: fromQuery,
|
|
48
|
+
verificationUri: url.toString()
|
|
49
|
+
};
|
|
50
|
+
const segments = url.pathname.split(`/`).filter(Boolean);
|
|
51
|
+
if (segments.length < 2) return null;
|
|
52
|
+
return {
|
|
53
|
+
userCode: segments[segments.length - 1],
|
|
54
|
+
verificationUri: url.toString()
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return /^[A-Za-z0-9][A-Za-z0-9\s._-]{2,63}$/u.test(trimmed) ? { userCode: trimmed } : null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/challenge.ts
|
|
62
|
+
/**
|
|
63
|
+
* Confirmation challenges for the approving device.
|
|
64
|
+
*
|
|
65
|
+
* RFC 8628 §5.4: the approval screen SHOULD show the code and ask the user to
|
|
66
|
+
* verify it matches the device in front of them. Scanning a QR skips the
|
|
67
|
+
* typing, which also skips the moment where the user would have noticed the
|
|
68
|
+
* code was wrong — so the check has to be reintroduced deliberately.
|
|
69
|
+
*
|
|
70
|
+
* How much friction that check deserves is a product decision, not a protocol
|
|
71
|
+
* one. Discord and Steam ship a bare "Approve?"; GitHub's sudo flow makes you
|
|
72
|
+
* type a code; Google shows three numbers and asks you to pick the one on the
|
|
73
|
+
* other screen. All are legitimate for different risk levels, so the strategy
|
|
74
|
+
* is pluggable and the machine only cares whether it passed.
|
|
75
|
+
*
|
|
76
|
+
* These run on the ALREADY AUTHENTICATED device. They do not authenticate the
|
|
77
|
+
* user — they establish that the user can see the screen requesting access.
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* No challenge — approving is a single tap.
|
|
81
|
+
*
|
|
82
|
+
* What Discord and Steam ship. Appropriate when the QR was scanned inside your
|
|
83
|
+
* own authenticated app, which already proves possession of the phone. Note
|
|
84
|
+
* this is weaker than RFC 8628 §5.4 asks for: nothing stops a user approving a
|
|
85
|
+
* code they never actually looked at.
|
|
86
|
+
*/
|
|
87
|
+
const noChallenge = () => ({
|
|
88
|
+
kind: `none`,
|
|
89
|
+
present: () => null,
|
|
90
|
+
verify: async () => Promise.resolve({ ok: true })
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* The user types the code shown on the device.
|
|
94
|
+
*
|
|
95
|
+
* The RFC's own suggestion, and GitHub's sudo pattern. Highest friction,
|
|
96
|
+
* strongest guarantee: an attacker who phished the QR cannot supply a code the
|
|
97
|
+
* user can read off their own screen.
|
|
98
|
+
*/
|
|
99
|
+
const codeEntryChallenge = () => ({
|
|
100
|
+
kind: `code-entry`,
|
|
101
|
+
present: () => ({ kind: `code-entry` }),
|
|
102
|
+
verify: async (answer, userCode) => Promise.resolve(normalizeUserCode(answer) === normalizeUserCode(userCode) ? { ok: true } : {
|
|
103
|
+
ok: false,
|
|
104
|
+
reason: `mismatch`
|
|
105
|
+
})
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
108
|
+
* The user picks the real code from a small set of decoys.
|
|
109
|
+
*
|
|
110
|
+
* Google's mobile approval pattern. Most of code-entry's guarantee at a
|
|
111
|
+
* fraction of the friction — one tap instead of eight keystrokes — which
|
|
112
|
+
* matters on a phone. A wrong pick is a real signal: the user is not looking
|
|
113
|
+
* at the device they are authorizing.
|
|
114
|
+
*/
|
|
115
|
+
const tripletChallenge = ({ decoys = 2, generate, shuffle = defaultShuffle }) => ({
|
|
116
|
+
kind: `triplet`,
|
|
117
|
+
present: (userCode) => {
|
|
118
|
+
const choices = /* @__PURE__ */ new Set([normalizeUserCode(userCode)]);
|
|
119
|
+
for (let attempt = 0; choices.size <= decoys && attempt < decoys * 10; attempt++) choices.add(normalizeUserCode(generate()));
|
|
120
|
+
return {
|
|
121
|
+
kind: `triplet`,
|
|
122
|
+
choices: shuffle([...choices])
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
verify: async (answer, userCode) => Promise.resolve(normalizeUserCode(answer) === normalizeUserCode(userCode) ? { ok: true } : {
|
|
126
|
+
ok: false,
|
|
127
|
+
reason: `mismatch`
|
|
128
|
+
})
|
|
129
|
+
});
|
|
130
|
+
/**
|
|
131
|
+
* Delegate to something the platform provides — FaceID, Touch ID, WebAuthn, a
|
|
132
|
+
* device passcode.
|
|
133
|
+
*
|
|
134
|
+
* hanko cannot implement these: they are platform APIs with no common
|
|
135
|
+
* interface. What it can do is give them a slot in the same machine, so a host
|
|
136
|
+
* that has one is not forced outside the flow to use it.
|
|
137
|
+
*
|
|
138
|
+
* A biometric proves possession of the phone, NOT that the user looked at the
|
|
139
|
+
* device screen — so for a public taproom TV, pair it with `codeEntry` or
|
|
140
|
+
* `triplet` rather than using it alone.
|
|
141
|
+
*/
|
|
142
|
+
const platformChallenge = ({ kind = `platform`, authenticate }) => ({
|
|
143
|
+
kind,
|
|
144
|
+
present: () => ({ kind }),
|
|
145
|
+
verify: async (_answer, userCode) => {
|
|
146
|
+
try {
|
|
147
|
+
return await authenticate(userCode) ? { ok: true } : {
|
|
148
|
+
ok: false,
|
|
149
|
+
reason: `cancelled`
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: `unavailable`
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
/**
|
|
160
|
+
* Require several challenges in order.
|
|
161
|
+
*
|
|
162
|
+
* The composition that makes sense in practice: a biometric proves the phone,
|
|
163
|
+
* a code check proves the screen. Neither alone covers both.
|
|
164
|
+
*
|
|
165
|
+
* Short-circuits on the first failure, and its `present` returns every prompt
|
|
166
|
+
* so a UI can render them as steps.
|
|
167
|
+
*/
|
|
168
|
+
const allOf = (strategies) => ({
|
|
169
|
+
kind: `all-of`,
|
|
170
|
+
present: (userCode) => strategies.map((s) => s.present(userCode)),
|
|
171
|
+
verify: async (answers, userCode) => {
|
|
172
|
+
for (const [index, strategy] of strategies.entries()) {
|
|
173
|
+
const result = await strategy.verify(answers[index], userCode);
|
|
174
|
+
if (!result.ok) return result;
|
|
175
|
+
}
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
/**
|
|
180
|
+
* Fisher-Yates over a copy.
|
|
181
|
+
*
|
|
182
|
+
* Not `sort(() => Math.random() - 0.5)`, which is biased — with three choices
|
|
183
|
+
* the real code would land in one position noticeably more often, and a user
|
|
184
|
+
* who learned that could pass the challenge without reading anything.
|
|
185
|
+
*/
|
|
186
|
+
const defaultShuffle = (items) => {
|
|
187
|
+
const copy = [...items];
|
|
188
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
189
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
190
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
191
|
+
}
|
|
192
|
+
return copy;
|
|
193
|
+
};
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/approve/index.ts
|
|
196
|
+
/**
|
|
197
|
+
* The approving device's client — the third participant.
|
|
198
|
+
*
|
|
199
|
+
* Runs on the already-authenticated phone. UI-agnostic by design: it exposes
|
|
200
|
+
* state and lifecycle hooks rather than components, so React, Vue, Svelte,
|
|
201
|
+
* Solid, and React Native each bind it with their own conventions. Nothing
|
|
202
|
+
* here touches the DOM.
|
|
203
|
+
*
|
|
204
|
+
* Covers both topologies the ecosystem uses:
|
|
205
|
+
*
|
|
206
|
+
* - **Plex**: the OS camera opens a URL. Call `submitCode` with the code from
|
|
207
|
+
* the query string; there is no scanning step.
|
|
208
|
+
* - **Discord / Steam**: the app scans in-place. Call `scan` with frames from
|
|
209
|
+
* your camera; the user never leaves the app.
|
|
210
|
+
*/
|
|
211
|
+
var ApprovalClient = class {
|
|
212
|
+
#resolve;
|
|
213
|
+
#submit;
|
|
214
|
+
#challenge;
|
|
215
|
+
#scanner;
|
|
216
|
+
#codeParam;
|
|
217
|
+
#hooks;
|
|
218
|
+
#state = `idle`;
|
|
219
|
+
#grant;
|
|
220
|
+
#prompt;
|
|
221
|
+
#challengePassed = false;
|
|
222
|
+
#challengeAttempts = 0;
|
|
223
|
+
constructor({ resolve, submit, challenge = noChallenge(), scanner, codeParam = `user_code`, hooks = {} }) {
|
|
224
|
+
this.#resolve = resolve;
|
|
225
|
+
this.#submit = submit;
|
|
226
|
+
this.#challenge = challenge;
|
|
227
|
+
this.#scanner = scanner;
|
|
228
|
+
this.#codeParam = codeParam;
|
|
229
|
+
this.#hooks = hooks;
|
|
230
|
+
}
|
|
231
|
+
get state() {
|
|
232
|
+
return this.#state;
|
|
233
|
+
}
|
|
234
|
+
get settled() {
|
|
235
|
+
return isApprovalSettled(this.#state);
|
|
236
|
+
}
|
|
237
|
+
/** The resolved grant, once known. Show its code for the user to verify. */
|
|
238
|
+
get grant() {
|
|
239
|
+
return this.#grant;
|
|
240
|
+
}
|
|
241
|
+
/** Whatever the challenge wants rendered — triplet choices, a prompt kind. */
|
|
242
|
+
get challengePrompt() {
|
|
243
|
+
return this.#prompt;
|
|
244
|
+
}
|
|
245
|
+
get challengeKind() {
|
|
246
|
+
return this.#challenge.kind;
|
|
247
|
+
}
|
|
248
|
+
/** Whether the challenge has been satisfied, so approval may proceed. */
|
|
249
|
+
get confirmed() {
|
|
250
|
+
return this.#challengePassed;
|
|
251
|
+
}
|
|
252
|
+
#send(event) {
|
|
253
|
+
const from = this.#state;
|
|
254
|
+
const to = approvalTransition(from, event);
|
|
255
|
+
if (to === from) return false;
|
|
256
|
+
this.#state = to;
|
|
257
|
+
this.#hooks.onTransition?.(from, to);
|
|
258
|
+
if (isApprovalSettled(to)) this.#hooks.onSettled?.(to);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
/** Open the scanner (in-app path). */
|
|
262
|
+
startScanning() {
|
|
263
|
+
this.#send({ type: `SCAN` });
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Offer a camera frame to the scanner.
|
|
267
|
+
*
|
|
268
|
+
* Returns the code when this frame contained one. Call it per frame and
|
|
269
|
+
* ignore the nulls — a camera pointed at a room sees unrelated codes
|
|
270
|
+
* constantly, and each is a non-event rather than an error.
|
|
271
|
+
*/
|
|
272
|
+
async scan(source) {
|
|
273
|
+
if (!this.#scanner) throw new Error(`no scanner configured`);
|
|
274
|
+
if (this.#state !== `scanning`) return null;
|
|
275
|
+
let detected;
|
|
276
|
+
try {
|
|
277
|
+
detected = await this.#scanner.detect(source);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
this.#send({ type: `ERROR` });
|
|
280
|
+
this.#hooks.onError?.(error, this.#state);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
for (const { rawValue } of detected) {
|
|
284
|
+
const payload = parseScannedCode(rawValue, { param: this.#codeParam });
|
|
285
|
+
if (payload) {
|
|
286
|
+
await this.submitCode(payload.userCode);
|
|
287
|
+
return payload.userCode;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Hand over a code from the URL (Plex path) or a manual entry field.
|
|
294
|
+
*
|
|
295
|
+
* The same entry point as a successful scan, so both topologies converge on
|
|
296
|
+
* one flow from here.
|
|
297
|
+
*/
|
|
298
|
+
async submitCode(userCode) {
|
|
299
|
+
if (!this.#send({
|
|
300
|
+
type: `CODE`,
|
|
301
|
+
userCode
|
|
302
|
+
})) return false;
|
|
303
|
+
this.#hooks.onCode?.(userCode);
|
|
304
|
+
let resolved;
|
|
305
|
+
try {
|
|
306
|
+
resolved = await this.#resolve(userCode);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
this.#send({ type: `ERROR` });
|
|
309
|
+
this.#hooks.onError?.(error, this.#state);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
if (!resolved) {
|
|
313
|
+
this.#send({ type: `REJECTED` });
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
this.#grant = resolved;
|
|
317
|
+
this.#prompt = this.#challenge.present(resolved.userCode);
|
|
318
|
+
this.#challengePassed = false;
|
|
319
|
+
this.#challengeAttempts = 0;
|
|
320
|
+
this.#send({ type: `RESOLVED` });
|
|
321
|
+
this.#hooks.onResolved?.(resolved, this.#prompt);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Answer the confirmation challenge.
|
|
326
|
+
*
|
|
327
|
+
* Separate from {@link approve} so a UI can verify as the user types (or as
|
|
328
|
+
* a biometric returns) and enable the approve button only once it passes.
|
|
329
|
+
*/
|
|
330
|
+
async confirm(answer) {
|
|
331
|
+
if (this.#state !== `confirming` || !this.#grant) return false;
|
|
332
|
+
const result = await this.#challenge.verify(answer, this.#grant.userCode);
|
|
333
|
+
this.#challengeAttempts += 1;
|
|
334
|
+
if (!result.ok) {
|
|
335
|
+
this.#challengePassed = false;
|
|
336
|
+
this.#send({ type: `CHALLENGE_FAILED` });
|
|
337
|
+
this.#hooks.onChallengeFailed?.(result.reason, this.#challengeAttempts);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
this.#challengePassed = true;
|
|
341
|
+
this.#send({ type: `CONFIRMED` });
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Approve the sign-in.
|
|
346
|
+
*
|
|
347
|
+
* Refuses while the challenge is unsatisfied. That guard is the point of the
|
|
348
|
+
* challenge existing: a UI bug that wires the button straight to this method
|
|
349
|
+
* must not be able to skip the check.
|
|
350
|
+
*/
|
|
351
|
+
async approve() {
|
|
352
|
+
if (!this.#challengePassed) return false;
|
|
353
|
+
return this.#decide(true);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Refuse the sign-in.
|
|
357
|
+
*
|
|
358
|
+
* Deliberately NOT gated on the challenge. A user who cannot confirm a code
|
|
359
|
+
* is exactly the user most likely to be looking at a phishing attempt, and
|
|
360
|
+
* they must always be able to say no.
|
|
361
|
+
*/
|
|
362
|
+
async deny() {
|
|
363
|
+
return this.#decide(false);
|
|
364
|
+
}
|
|
365
|
+
async #decide(approved) {
|
|
366
|
+
if (!this.#grant) return false;
|
|
367
|
+
if (!this.#send({ type: approved ? `APPROVE` : `DENY` })) return false;
|
|
368
|
+
try {
|
|
369
|
+
await this.#submit(this.#grant.userCode, approved);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
this.#send({ type: `ERROR` });
|
|
372
|
+
this.#hooks.onError?.(error, this.#state);
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
this.#send({
|
|
376
|
+
type: `SUBMITTED`,
|
|
377
|
+
approved
|
|
378
|
+
});
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
/** Start over after a failure or an unrecognized code. */
|
|
382
|
+
reset() {
|
|
383
|
+
this.#grant = void 0;
|
|
384
|
+
this.#prompt = void 0;
|
|
385
|
+
this.#challengePassed = false;
|
|
386
|
+
this.#challengeAttempts = 0;
|
|
387
|
+
this.#send({ type: `RESET` });
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
//#endregion
|
|
391
|
+
export { ApprovalClient, allOf, appleAppSiteAssociation, buildAppSchemeUrl, buildApprovalUrl, codeEntryChallenge, consumeLaunchTarget, createBarcodeDetectorScanner, digitalAssetLinks, expoLinkingConfig, hasNativeBarcodeDetector, noChallenge, parseApprovalLink, parseScannedCode, platformChallenge, pwaLaunchHandler, tripletChallenge };
|
|
392
|
+
|
|
393
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["global","#resolve","#submit","#challenge","#scanner","#codeParam","#hooks","#state","#grant","#prompt","#challengePassed","#send","#challengeAttempts","#decide"],"sources":["../../src/scanner.ts","../../src/challenge.ts","../../src/approve/index.ts"],"sourcesContent":["/**\n * QR reading for the approving device.\n *\n * Generation is dependency-free; decoding cannot be. hanko defines the\n * {@link QrScanner} interface — two methods — and lets the host bring a\n * decoder rather than picking one for everybody.\n *\n * That indirection is not ceremony. `BarcodeDetector` looks like the obvious\n * answer and is NOT a web standard: it is a WICG incubation that MDN flags as\n * outside Baseline, Safari has never shipped it, and no vendor has committed\n * to it. Building against it directly means the scanner silently does nothing\n * on an iPhone — the device most people approve from.\n *\n * What works today, in rough order of preference:\n *\n * - **`qr-scanner`** — ~6 kB gzipped, self-contained, uses a native\n * `BarcodeDetector` where one exists and its own worker otherwise. It owns\n * the camera too, which is most of the work. See the Astro example.\n * - **`barcode-detector`** — a ponyfill over ZXing-C++/WASM. More formats and\n * actively maintained, but it fetches its WASM from a CDN at runtime, which\n * is a poor default on an authentication screen.\n * - **`expo-camera`** on React Native, where no web API exists at all.\n *\n * {@link createBarcodeDetectorScanner} remains for the native path and for\n * ponyfills mirroring that API.\n */\n\n/** Minimal shape of a detected barcode. Mirrors the Barcode Detection API. */\n/**\n * A camera frame, as an opaque handle.\n *\n * The DOM's `ImageBitmapSource` in a browser — a `<video>`, `ImageBitmap`,\n * `Blob`, or canvas — but declared locally rather than pulled from `lib.dom`.\n * This package compiles without the DOM lib on purpose, so server code cannot\n * reach a browser-only global by accident, and so the same types work for a\n * React Native frame that is not an `ImageBitmapSource` at all.\n *\n * hanko never inspects the frame; it passes it straight to the detector.\n */\nexport type CameraFrame = unknown;\n\n/** Minimal shape of a detected barcode. Mirrors the Barcode Detection API. */\nexport interface DetectedBarcode {\n rawValue: string;\n}\n\n/**\n * What hanko needs from a scanner: turn a frame into strings.\n *\n * Deliberately narrower than `BarcodeDetector` — no bounding boxes, no\n * formats — so an `expo-camera` or jsQR adapter is trivial to write.\n */\nexport interface QrScanner {\n detect(source: CameraFrame): Promise<DetectedBarcode[]>;\n}\n\n/** The subset of the global `BarcodeDetector` this module uses. */\ninterface BarcodeDetectorLike {\n detect(source: CameraFrame): Promise<DetectedBarcode[]>;\n}\n\ninterface BarcodeDetectorConstructor {\n new (options?: { formats?: string[] }): BarcodeDetectorLike;\n}\n\n/** Whether this runtime has a native `BarcodeDetector`. */\nexport const hasNativeBarcodeDetector = (): boolean =>\n typeof globalThis === `object` && `BarcodeDetector` in globalThis;\n\n/**\n * Scanner backed by the platform's `BarcodeDetector`.\n *\n * Pass the ponyfill's constructor where the native one is missing:\n *\n * ```ts\n * import { BarcodeDetector } from \"barcode-detector/ponyfill\";\n * const scanner = createBarcodeDetectorScanner({ detector: BarcodeDetector });\n * ```\n *\n * Restricted to `qr_code`: a taplist beer can carries an EAN-13 barcode, and a\n * scanner that reported it here would send a product code to the approval\n * endpoint as though it were a user code.\n */\nexport const createBarcodeDetectorScanner = ({\n detector\n}: {\n /** Constructor to use. Defaults to the global when present. */\n detector?: BarcodeDetectorConstructor;\n} = {}): QrScanner => {\n // Read through a narrowing check rather than a cast: `BarcodeDetector` is\n // absent on every server runtime, so its presence has to be tested rather\n // than assumed.\n const global: Record<string, unknown> = globalThis;\n const fromGlobal = global.BarcodeDetector;\n const Ctor =\n detector ??\n (typeof fromGlobal === `function`\n ? // Unavoidable: the platform global is untyped here (no DOM lib), and\n // `typeof === \"function\"` is as far as a runtime check can narrow a\n // constructor. A wrong global would fail at `new`, not silently.\n // oxlint-disable-next-line typescript/no-unsafe-type-assertion\n (fromGlobal as unknown as BarcodeDetectorConstructor)\n : undefined);\n\n if (!Ctor) {\n throw new Error(\n `No BarcodeDetector available. Install \"barcode-detector\" and pass its ponyfill constructor.`\n );\n }\n\n const instance = new Ctor({ formats: [`qr_code`] });\n return { detect: async (source) => instance.detect(source) };\n};\n\n/** Everything hanko can pull out of a scanned payload. */\nexport interface ScannedPayload {\n /** The code to send to the approval endpoint. */\n userCode: string;\n /** The URL the QR encoded, when it was a URL. */\n verificationUri?: string;\n}\n\n/**\n * Read a `user_code` out of a scanned string.\n *\n * QRs in this flow carry `verification_uri_complete` — a URL with the code in\n * it — but a scanner may also see a bare code from a hand-typed fallback, so\n * both are accepted.\n *\n * Returns `null` rather than throwing: a camera pointed at the world sees\n * unrelated codes constantly, and each one is a normal non-event, not an\n * error.\n */\nexport const parseScannedCode = (\n raw: string,\n { param = `user_code` }: { param?: string } = {}\n): ScannedPayload | null => {\n const trimmed = raw.trim();\n if (trimmed.length === 0) return null;\n\n try {\n const url = new URL(trimmed);\n // http/https only. `new URL` accepts ANY scheme, so a café's WiFi QR\n // (`WIFI:S:cafe;T:WPA;...`) parses cleanly and its payload would sail\n // through the path fallback below as though it were a user code.\n if (url.protocol !== `http:` && url.protocol !== `https:`) return null;\n\n const fromQuery = url.searchParams.get(param);\n if (fromQuery !== null && fromQuery.length > 0) {\n return { userCode: fromQuery, verificationUri: url.toString() };\n }\n // Some hosts put the code in the path (`/link/WDJB-MJHT`) rather than the\n // query. Only worth reading when there is a segment BEYOND the route\n // itself: a bare `/link` would otherwise yield the code \"link\", which the\n // server duly rejects — and a spurious rejection on page load looks to the\n // user like their real code failed.\n const segments = url.pathname.split(`/`).filter(Boolean);\n if (segments.length < 2) return null;\n\n const segment = segments[segments.length - 1];\n return { userCode: segment, verificationUri: url.toString() };\n } catch {\n // Not a URL. Treat it as a bare code, but only if it could plausibly be\n // one — otherwise every QR in the room becomes a candidate code.\n return /^[A-Za-z0-9][A-Za-z0-9\\s._-]{2,63}$/u.test(trimmed)\n ? { userCode: trimmed }\n : null;\n }\n};\n","/**\n * Confirmation challenges for the approving device.\n *\n * RFC 8628 §5.4: the approval screen SHOULD show the code and ask the user to\n * verify it matches the device in front of them. Scanning a QR skips the\n * typing, which also skips the moment where the user would have noticed the\n * code was wrong — so the check has to be reintroduced deliberately.\n *\n * How much friction that check deserves is a product decision, not a protocol\n * one. Discord and Steam ship a bare \"Approve?\"; GitHub's sudo flow makes you\n * type a code; Google shows three numbers and asks you to pick the one on the\n * other screen. All are legitimate for different risk levels, so the strategy\n * is pluggable and the machine only cares whether it passed.\n *\n * These run on the ALREADY AUTHENTICATED device. They do not authenticate the\n * user — they establish that the user can see the screen requesting access.\n */\n\nimport { normalizeUserCode } from \"./codes.js\";\n\n/** Outcome of a challenge attempt. */\nexport interface ChallengeResult {\n ok: boolean;\n /** Present when `ok` is false, for a precise message. */\n reason?: `mismatch` | `cancelled` | `unavailable`;\n}\n\n/**\n * A confirmation strategy.\n *\n * `present` gives the UI whatever it needs to render (the decoys for a\n * triplet, say); `verify` checks the user's answer. Async because a biometric\n * or WebAuthn prompt is inherently asynchronous.\n */\nexport interface ChallengeStrategy<TPrompt = unknown, TAnswer = unknown> {\n /** Stable name, so a UI can switch on which challenge it is rendering. */\n readonly kind: string;\n /** What the UI should show. Called once per confirmation. */\n present(userCode: string): TPrompt;\n /** Check the user's answer. */\n verify(answer: TAnswer, userCode: string): Promise<ChallengeResult>;\n}\n\n/**\n * No challenge — approving is a single tap.\n *\n * What Discord and Steam ship. Appropriate when the QR was scanned inside your\n * own authenticated app, which already proves possession of the phone. Note\n * this is weaker than RFC 8628 §5.4 asks for: nothing stops a user approving a\n * code they never actually looked at.\n */\nexport const noChallenge = (): ChallengeStrategy<null, void> => ({\n kind: `none`,\n present: () => null,\n verify: async () => Promise.resolve({ ok: true })\n});\n\n/**\n * The user types the code shown on the device.\n *\n * The RFC's own suggestion, and GitHub's sudo pattern. Highest friction,\n * strongest guarantee: an attacker who phished the QR cannot supply a code the\n * user can read off their own screen.\n */\nexport const codeEntryChallenge = (): ChallengeStrategy<\n { kind: `code-entry` },\n string\n> => ({\n kind: `code-entry`,\n present: () => ({ kind: `code-entry` }),\n verify: async (answer, userCode) =>\n Promise.resolve(\n // Normalized on both sides: the user is retyping from a screen, so\n // spacing, case, and the separator must not matter.\n normalizeUserCode(answer) === normalizeUserCode(userCode)\n ? { ok: true }\n : { ok: false, reason: `mismatch` }\n )\n});\n\n/** What a triplet challenge hands the UI. */\nexport interface TripletPrompt {\n kind: `triplet`;\n /** The real code plus decoys, already shuffled. Render as-is. */\n choices: string[];\n}\n\n/**\n * The user picks the real code from a small set of decoys.\n *\n * Google's mobile approval pattern. Most of code-entry's guarantee at a\n * fraction of the friction — one tap instead of eight keystrokes — which\n * matters on a phone. A wrong pick is a real signal: the user is not looking\n * at the device they are authorizing.\n */\nexport const tripletChallenge = ({\n decoys = 2,\n generate,\n shuffle = defaultShuffle\n}: {\n /** Decoys alongside the real code. Two gives the classic three-up. */\n decoys?: number;\n /** Produces a decoy. Must match the real code's shape to be plausible. */\n generate: () => string;\n /** Injectable for deterministic tests. */\n shuffle?: <T>(items: T[]) => T[];\n} & { generate: () => string }): ChallengeStrategy<TripletPrompt, string> => ({\n kind: `triplet`,\n present: (userCode) => {\n const choices = new Set<string>([normalizeUserCode(userCode)]);\n // Set-based with a bounded retry: a generator that happens to emit the\n // real code (or a duplicate) would otherwise render two identical\n // choices, which is unanswerable.\n for (\n let attempt = 0;\n choices.size <= decoys && attempt < decoys * 10;\n attempt++\n ) {\n choices.add(normalizeUserCode(generate()));\n }\n return { kind: `triplet`, choices: shuffle([...choices]) };\n },\n verify: async (answer, userCode) =>\n Promise.resolve(\n normalizeUserCode(answer) === normalizeUserCode(userCode)\n ? { ok: true }\n : { ok: false, reason: `mismatch` }\n )\n});\n\n/**\n * Delegate to something the platform provides — FaceID, Touch ID, WebAuthn, a\n * device passcode.\n *\n * hanko cannot implement these: they are platform APIs with no common\n * interface. What it can do is give them a slot in the same machine, so a host\n * that has one is not forced outside the flow to use it.\n *\n * A biometric proves possession of the phone, NOT that the user looked at the\n * device screen — so for a public taproom TV, pair it with `codeEntry` or\n * `triplet` rather than using it alone.\n */\nexport const platformChallenge = ({\n kind = `platform`,\n authenticate\n}: {\n kind?: string;\n /** Resolves true when the platform accepted the user. */\n authenticate: (userCode: string) => Promise<boolean>;\n}): ChallengeStrategy<{ kind: string }, void> => ({\n kind,\n present: () => ({ kind }),\n verify: async (_answer, userCode) => {\n try {\n return (await authenticate(userCode))\n ? { ok: true }\n : { ok: false, reason: `cancelled` };\n } catch {\n // A missing or broken platform API must not read as a refusal — the host\n // can fall back to another challenge.\n return { ok: false, reason: `unavailable` };\n }\n }\n});\n\n/**\n * Require several challenges in order.\n *\n * The composition that makes sense in practice: a biometric proves the phone,\n * a code check proves the screen. Neither alone covers both.\n *\n * Short-circuits on the first failure, and its `present` returns every prompt\n * so a UI can render them as steps.\n */\nexport const allOf = (\n strategies: ChallengeStrategy[]\n): ChallengeStrategy<unknown[], unknown[]> => ({\n kind: `all-of`,\n present: (userCode) => strategies.map((s) => s.present(userCode)),\n verify: async (answers, userCode) => {\n for (const [index, strategy] of strategies.entries()) {\n const result = await strategy.verify(answers[index], userCode);\n if (!result.ok) return result;\n }\n return { ok: true };\n }\n});\n\n/**\n * Fisher-Yates over a copy.\n *\n * Not `sort(() => Math.random() - 0.5)`, which is biased — with three choices\n * the real code would land in one position noticeably more often, and a user\n * who learned that could pass the challenge without reading anything.\n */\nconst defaultShuffle = <T>(items: T[]): T[] => {\n const copy = [...items];\n for (let i = copy.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n [copy[i], copy[j]] = [copy[j], copy[i]];\n }\n return copy;\n};\n","/**\n * The approving device's client — the third participant.\n *\n * Runs on the already-authenticated phone. UI-agnostic by design: it exposes\n * state and lifecycle hooks rather than components, so React, Vue, Svelte,\n * Solid, and React Native each bind it with their own conventions. Nothing\n * here touches the DOM.\n *\n * Covers both topologies the ecosystem uses:\n *\n * - **Plex**: the OS camera opens a URL. Call `submitCode` with the code from\n * the query string; there is no scanning step.\n * - **Discord / Steam**: the app scans in-place. Call `scan` with frames from\n * your camera; the user never leaves the app.\n */\n\nimport {\n approvalTransition,\n isApprovalSettled,\n type ApprovalEvent,\n type ApprovalState\n} from \"../machine.js\";\nimport {\n parseScannedCode,\n type CameraFrame,\n type QrScanner\n} from \"../scanner.js\";\nimport { noChallenge, type ChallengeStrategy } from \"../challenge.js\";\n\n/** What the approval endpoint tells us about a code. */\nexport interface ResolvedGrant {\n /** The code, as the server has it. Display this for the user to check. */\n userCode: string;\n /** Optional detail a host may show: which client is asking, and for what. */\n clientId?: string;\n scope?: string;\n}\n\nexport interface ApprovalHooks {\n /** Any successful transition. The main binding point for a UI. */\n onTransition?: (from: ApprovalState, to: ApprovalState) => void;\n /** A code was read. Fires before the server is asked about it. */\n onCode?: (userCode: string) => void;\n /** The server resolved the code. Carries what to show the user. */\n onResolved?: (grant: ResolvedGrant, prompt: unknown) => void;\n /** The challenge was answered incorrectly. The user may retry. */\n onChallengeFailed?: (reason: string | undefined, attempts: number) => void;\n /** Terminal: the decision was recorded. */\n onSettled?: (state: ApprovalState) => void;\n /** Recoverable failure — camera or network. */\n onError?: (error: unknown, state: ApprovalState) => void;\n}\n\nexport interface ApprovalClientOptions {\n /** Resolve a code to a grant. Usually `GET /link?user_code=…`. */\n resolve: (userCode: string) => Promise<ResolvedGrant | null>;\n /** Record the decision. Usually `POST /link`. */\n submit: (userCode: string, approved: boolean) => Promise<void>;\n /**\n * How the user proves they are looking at the requesting screen.\n *\n * Defaults to {@link noChallenge} — one tap, what Discord and Steam ship.\n * For a public screen prefer `codeEntryChallenge` or `tripletChallenge`,\n * which is what RFC 8628 §5.4 actually asks for.\n */\n challenge?: ChallengeStrategy;\n /** Needed only for the in-app scanning path. */\n scanner?: QrScanner;\n /** Query parameter carrying the code in scanned URLs. */\n codeParam?: string;\n hooks?: ApprovalHooks;\n}\n\nexport class ApprovalClient {\n readonly #resolve: (userCode: string) => Promise<ResolvedGrant | null>;\n readonly #submit: (userCode: string, approved: boolean) => Promise<void>;\n readonly #challenge: ChallengeStrategy;\n readonly #scanner: QrScanner | undefined;\n readonly #codeParam: string;\n readonly #hooks: ApprovalHooks;\n\n #state: ApprovalState = `idle`;\n #grant: ResolvedGrant | undefined;\n #prompt: unknown;\n #challengePassed = false;\n #challengeAttempts = 0;\n\n constructor({\n resolve,\n submit,\n challenge = noChallenge(),\n scanner,\n codeParam = `user_code`,\n hooks = {}\n }: ApprovalClientOptions) {\n this.#resolve = resolve;\n this.#submit = submit;\n this.#challenge = challenge;\n this.#scanner = scanner;\n this.#codeParam = codeParam;\n this.#hooks = hooks;\n }\n\n get state(): ApprovalState {\n return this.#state;\n }\n\n get settled(): boolean {\n return isApprovalSettled(this.#state);\n }\n\n /** The resolved grant, once known. Show its code for the user to verify. */\n get grant(): ResolvedGrant | undefined {\n return this.#grant;\n }\n\n /** Whatever the challenge wants rendered — triplet choices, a prompt kind. */\n get challengePrompt(): unknown {\n return this.#prompt;\n }\n\n get challengeKind(): string {\n return this.#challenge.kind;\n }\n\n /** Whether the challenge has been satisfied, so approval may proceed. */\n get confirmed(): boolean {\n return this.#challengePassed;\n }\n\n #send(event: ApprovalEvent): boolean {\n const from = this.#state;\n const to = approvalTransition(from, event);\n if (to === from) return false;\n\n this.#state = to;\n this.#hooks.onTransition?.(from, to);\n if (isApprovalSettled(to)) this.#hooks.onSettled?.(to);\n return true;\n }\n\n /** Open the scanner (in-app path). */\n startScanning(): void {\n this.#send({ type: `SCAN` });\n }\n\n /**\n * Offer a camera frame to the scanner.\n *\n * Returns the code when this frame contained one. Call it per frame and\n * ignore the nulls — a camera pointed at a room sees unrelated codes\n * constantly, and each is a non-event rather than an error.\n */\n async scan(source: CameraFrame): Promise<string | null> {\n if (!this.#scanner) throw new Error(`no scanner configured`);\n if (this.#state !== `scanning`) return null;\n\n let detected;\n try {\n detected = await this.#scanner.detect(source);\n } catch (error) {\n this.#send({ type: `ERROR` });\n this.#hooks.onError?.(error, this.#state);\n return null;\n }\n\n for (const { rawValue } of detected) {\n const payload = parseScannedCode(rawValue, { param: this.#codeParam });\n if (payload) {\n await this.submitCode(payload.userCode);\n return payload.userCode;\n }\n }\n return null;\n }\n\n /**\n * Hand over a code from the URL (Plex path) or a manual entry field.\n *\n * The same entry point as a successful scan, so both topologies converge on\n * one flow from here.\n */\n async submitCode(userCode: string): Promise<boolean> {\n if (!this.#send({ type: `CODE`, userCode })) return false;\n this.#hooks.onCode?.(userCode);\n\n let resolved: ResolvedGrant | null;\n try {\n resolved = await this.#resolve(userCode);\n } catch (error) {\n this.#send({ type: `ERROR` });\n this.#hooks.onError?.(error, this.#state);\n return false;\n }\n\n if (!resolved) {\n this.#send({ type: `REJECTED` });\n return false;\n }\n\n this.#grant = resolved;\n // Prompt built from the SERVER's code, not the scanned one: a triplet\n // built from an attacker-supplied string would present decoys around a\n // code that is not the one being authorized.\n this.#prompt = this.#challenge.present(resolved.userCode);\n this.#challengePassed = false;\n this.#challengeAttempts = 0;\n this.#send({ type: `RESOLVED` });\n this.#hooks.onResolved?.(resolved, this.#prompt);\n return true;\n }\n\n /**\n * Answer the confirmation challenge.\n *\n * Separate from {@link approve} so a UI can verify as the user types (or as\n * a biometric returns) and enable the approve button only once it passes.\n */\n async confirm(answer?: unknown): Promise<boolean> {\n if (this.#state !== `confirming` || !this.#grant) return false;\n\n const result = await this.#challenge.verify(answer, this.#grant.userCode);\n this.#challengeAttempts += 1;\n\n if (!result.ok) {\n this.#challengePassed = false;\n this.#send({ type: `CHALLENGE_FAILED` });\n this.#hooks.onChallengeFailed?.(result.reason, this.#challengeAttempts);\n return false;\n }\n\n this.#challengePassed = true;\n this.#send({ type: `CONFIRMED` });\n return true;\n }\n\n /**\n * Approve the sign-in.\n *\n * Refuses while the challenge is unsatisfied. That guard is the point of the\n * challenge existing: a UI bug that wires the button straight to this method\n * must not be able to skip the check.\n */\n async approve(): Promise<boolean> {\n if (!this.#challengePassed) return false;\n return this.#decide(true);\n }\n\n /**\n * Refuse the sign-in.\n *\n * Deliberately NOT gated on the challenge. A user who cannot confirm a code\n * is exactly the user most likely to be looking at a phishing attempt, and\n * they must always be able to say no.\n */\n async deny(): Promise<boolean> {\n return this.#decide(false);\n }\n\n async #decide(approved: boolean): Promise<boolean> {\n if (!this.#grant) return false;\n if (!this.#send({ type: approved ? `APPROVE` : `DENY` })) return false;\n\n try {\n await this.#submit(this.#grant.userCode, approved);\n } catch (error) {\n this.#send({ type: `ERROR` });\n this.#hooks.onError?.(error, this.#state);\n return false;\n }\n\n this.#send({ type: `SUBMITTED`, approved });\n return true;\n }\n\n /** Start over after a failure or an unrecognized code. */\n reset(): void {\n this.#grant = undefined;\n this.#prompt = undefined;\n this.#challengePassed = false;\n this.#challengeAttempts = 0;\n this.#send({ type: `RESET` });\n }\n}\n\nexport {\n allOf,\n codeEntryChallenge,\n noChallenge,\n platformChallenge,\n tripletChallenge\n} from \"../challenge.js\";\nexport type {\n ChallengeResult,\n ChallengeStrategy,\n TripletPrompt\n} from \"../challenge.js\";\nexport {\n createBarcodeDetectorScanner,\n hasNativeBarcodeDetector,\n parseScannedCode\n} from \"../scanner.js\";\nexport type {\n CameraFrame,\n DetectedBarcode,\n QrScanner,\n ScannedPayload\n} from \"../scanner.js\";\n\nexport {\n appleAppSiteAssociation,\n buildApprovalUrl,\n buildAppSchemeUrl,\n consumeLaunchTarget,\n digitalAssetLinks,\n expoLinkingConfig,\n parseApprovalLink,\n pwaLaunchHandler\n} from \"../linking.js\";\nexport type { LinkConfig, LinkSource, ParsedApprovalLink } from \"../linking.js\";\n"],"mappings":"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"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { c as PollState, o as PollContext } from "../machine-CRHKjtoP.mjs";
|
|
2
|
+
import { n as DeviceAuthorizationError, r as DeviceAuthorizationResponse } from "../types-C82lb-zX.mjs";
|
|
3
|
+
//#region src/client/index.d.ts
|
|
4
|
+
/** What the host's token endpoint returns. Mirrors RFC 8628 §3.5. */
|
|
5
|
+
interface TokenEndpointResponse {
|
|
6
|
+
error?: DeviceAuthorizationError;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
/** Observers of the poll loop. All optional, all fire after the move. */
|
|
10
|
+
interface PollHooks {
|
|
11
|
+
/** Any successful transition, with the context that produced it. */
|
|
12
|
+
onTransition?: (from: PollState, to: PollState, context: PollContext) => void;
|
|
13
|
+
/** A poll returned `authorization_pending`. Good place for a "still waiting" cue. */
|
|
14
|
+
onPending?: (context: PollContext) => void;
|
|
15
|
+
/**
|
|
16
|
+
* The server asked us to slow down. Receives the NEW interval, so a UI can
|
|
17
|
+
* show the changed cadence rather than a stale one.
|
|
18
|
+
*/
|
|
19
|
+
onSlowDown?: (intervalSeconds: number, context: PollContext) => void;
|
|
20
|
+
/** A request failed at the network level. The flow continues. */
|
|
21
|
+
onNetworkError?: (error: unknown, context: PollContext) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Cancellable delay. Exported so callers (and tests) reuse the same abort-aware
|
|
25
|
+
* behavior the loop relies on rather than reimplementing it.
|
|
26
|
+
*/
|
|
27
|
+
declare const sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
28
|
+
interface DeviceAuthClientOptions {
|
|
29
|
+
/** Token endpoint URL. */
|
|
30
|
+
tokenUrl: string;
|
|
31
|
+
/** From the authorization response. */
|
|
32
|
+
deviceCode: string;
|
|
33
|
+
/** Seconds between polls, from the authorization response. */
|
|
34
|
+
interval: number;
|
|
35
|
+
/** Seconds until both codes die, from the authorization response. */
|
|
36
|
+
expiresIn: number;
|
|
37
|
+
/** Sent as `client_id` when the host app tracks clients. */
|
|
38
|
+
clientId?: string;
|
|
39
|
+
/** Lifecycle observers. */
|
|
40
|
+
hooks?: PollHooks;
|
|
41
|
+
/** Injectable for tests. */
|
|
42
|
+
fetchImpl?: typeof fetch;
|
|
43
|
+
/** Injectable clock, for tests. */
|
|
44
|
+
now?: () => number;
|
|
45
|
+
/**
|
|
46
|
+
* Injectable delay, for tests.
|
|
47
|
+
*
|
|
48
|
+
* Exposed so the growing intervals (`slow_down`, network backoff) can be
|
|
49
|
+
* asserted without a suite that actually waits minutes for them.
|
|
50
|
+
*/
|
|
51
|
+
sleepImpl?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/** Terminal outcomes of the loop. */
|
|
54
|
+
type AuthorizationOutcome = {
|
|
55
|
+
status: `authorized`;
|
|
56
|
+
tokens: TokenEndpointResponse;
|
|
57
|
+
} | {
|
|
58
|
+
status: `denied`;
|
|
59
|
+
} | {
|
|
60
|
+
status: `expired`;
|
|
61
|
+
} | {
|
|
62
|
+
status: `aborted`;
|
|
63
|
+
};
|
|
64
|
+
declare class DeviceAuthClient {
|
|
65
|
+
#private;
|
|
66
|
+
constructor({ tokenUrl, deviceCode, interval, expiresIn, clientId, hooks, fetchImpl, now, sleepImpl }: DeviceAuthClientOptions);
|
|
67
|
+
get state(): PollState;
|
|
68
|
+
/** Snapshot of the extended state — interval, deadline, attempts. */
|
|
69
|
+
get context(): Readonly<PollContext>;
|
|
70
|
+
get settled(): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Run until the flow resolves.
|
|
73
|
+
*
|
|
74
|
+
* Resolves rather than throws on every RFC-defined terminal state — denial
|
|
75
|
+
* and expiry are normal outcomes the UI must render, not exceptions.
|
|
76
|
+
*/
|
|
77
|
+
run(signal?: AbortSignal): Promise<AuthorizationOutcome>;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Poll until the flow resolves.
|
|
81
|
+
*
|
|
82
|
+
* Function form of {@link DeviceAuthClient} for callers who do not need to
|
|
83
|
+
* observe state — the common case on a TV screen that only cares about the
|
|
84
|
+
* final outcome.
|
|
85
|
+
*/
|
|
86
|
+
declare const pollUntilAuthorized: ({ signal, onPending, onSlowDown, ...options }: DeviceAuthClientOptions & {
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
/** Convenience passthroughs, so existing callers keep working. */
|
|
89
|
+
onPending?: () => void;
|
|
90
|
+
onSlowDown?: (nextIntervalSeconds: number) => void;
|
|
91
|
+
}) => Promise<AuthorizationOutcome>;
|
|
92
|
+
/** Convenience: start a flow against the host's device-authorization endpoint. */
|
|
93
|
+
declare const requestDeviceAuthorization: ({ authorizationUrl, clientId, scope, fetchImpl }: {
|
|
94
|
+
authorizationUrl: string;
|
|
95
|
+
clientId?: string;
|
|
96
|
+
scope?: string;
|
|
97
|
+
fetchImpl?: typeof fetch;
|
|
98
|
+
}) => Promise<DeviceAuthorizationResponse>;
|
|
99
|
+
//#endregion
|
|
100
|
+
export { AuthorizationOutcome, DeviceAuthClient, DeviceAuthClientOptions, PollHooks, TokenEndpointResponse, pollUntilAuthorized, requestDeviceAuthorization, sleep };
|
|
101
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/client/index.ts"],"mappings":";;;;UA+BiB;EACf,QAAQ;GACP;;;UAIc;;EAEf,gBAAgB,MAAM,WAAW,IAAI,WAAW,SAAS;;EAEzD,aAAa,SAAS;;;;;EAKtB,cAAc,yBAAyB,SAAS;;EAEhD,kBAAkB,gBAAgB,SAAS;;;;;;cAOhC,QACX,YACA,SAAS,gBACR;UAiBc;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA,QAAQ;;EAER,mBAAmB;;EAEnB;;;;;;;EAOA,aAAa,YAAY,SAAS,gBAAgB;;;KAIxC;EACN;EAAsB,QAAQ;;EAC9B;;EACA;;EACA;;cAEO;;EAaC,cACV,UACA,YACA,UACA,WACA,UACA,OACA,WACA,KACA,aACC;MAeC,SAAS;;MAKT,WAAW,SAAS;MAIpB;;;;;;;EAqBE,IAAI,SAAS,cAAc,QAAQ;;;;;;;;;cAmH9B,wBAA6B,QAAA,WAAA,eAAA,WAKvC;EACD,SAAS;;EAET;EACA,cAAc;MACZ,QAAQ;;cAgDC,+BAAoC,kBAAA,UAAA,OAAA;EAM/C;EACA;EACA;EACA,mBAAmB;MACjB,QAAQ"}
|