@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,215 @@
|
|
|
1
|
+
import { d as isPollSettled, f as pollContextTransition, p as pollTransition, s as eventForTokenError } from "../machine-D_5DAFxi.mjs";
|
|
2
|
+
import { t as DEVICE_CODE_GRANT_TYPE } from "../types-BvBIFPH6.mjs";
|
|
3
|
+
//#region src/client/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* Device-side polling loop (RFC 8628).
|
|
6
|
+
*
|
|
7
|
+
* Runs on the constrained device (TV, kiosk, Pi). Deliberately dependency-free
|
|
8
|
+
* and DOM-free so it works in a Fire OS WebView, a browser, or Node.
|
|
9
|
+
*
|
|
10
|
+
* Transport is polling only, per the spec. No SSE, no WebSocket: this screen
|
|
11
|
+
* may stay powered on for days, and a persistent connection is one more thing
|
|
12
|
+
* to leak, reconnect, and debug on hardware we cannot attach a profiler to.
|
|
13
|
+
*
|
|
14
|
+
* The loop is a state machine (`machine.ts`) wrapped in a class that owns the
|
|
15
|
+
* state and the in-flight `device_code`. Hooks let a UI render every transition
|
|
16
|
+
* without reaching into that state.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Cancellable delay. Exported so callers (and tests) reuse the same abort-aware
|
|
20
|
+
* behavior the loop relies on rather than reimplementing it.
|
|
21
|
+
*/
|
|
22
|
+
const sleep = async (ms, signal) => {
|
|
23
|
+
if (signal?.aborted) return;
|
|
24
|
+
await new Promise((resolve) => {
|
|
25
|
+
const finish = () => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
signal?.removeEventListener(`abort`, finish);
|
|
28
|
+
resolve();
|
|
29
|
+
};
|
|
30
|
+
const timer = setTimeout(finish, ms);
|
|
31
|
+
signal?.addEventListener(`abort`, finish, { once: true });
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
var DeviceAuthClient = class {
|
|
35
|
+
#tokenUrl;
|
|
36
|
+
#deviceCode;
|
|
37
|
+
#clientId;
|
|
38
|
+
#hooks;
|
|
39
|
+
#fetch;
|
|
40
|
+
#now;
|
|
41
|
+
#sleep;
|
|
42
|
+
#state = `idle`;
|
|
43
|
+
#context;
|
|
44
|
+
#tokens;
|
|
45
|
+
constructor({ tokenUrl, deviceCode, interval, expiresIn, clientId, hooks = {}, fetchImpl = fetch, now = () => Date.now(), sleepImpl = sleep }) {
|
|
46
|
+
this.#tokenUrl = tokenUrl;
|
|
47
|
+
this.#deviceCode = deviceCode;
|
|
48
|
+
this.#clientId = clientId;
|
|
49
|
+
this.#hooks = hooks;
|
|
50
|
+
this.#fetch = fetchImpl;
|
|
51
|
+
this.#now = now;
|
|
52
|
+
this.#sleep = sleepImpl;
|
|
53
|
+
this.#context = {
|
|
54
|
+
intervalSeconds: interval,
|
|
55
|
+
deadline: now() + expiresIn * 1e3,
|
|
56
|
+
attempts: 0
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
get state() {
|
|
60
|
+
return this.#state;
|
|
61
|
+
}
|
|
62
|
+
/** Snapshot of the extended state — interval, deadline, attempts. */
|
|
63
|
+
get context() {
|
|
64
|
+
return { ...this.#context };
|
|
65
|
+
}
|
|
66
|
+
get settled() {
|
|
67
|
+
return isPollSettled(this.#state);
|
|
68
|
+
}
|
|
69
|
+
/** Apply an event to both reducers, then fire hooks. */
|
|
70
|
+
#send(event) {
|
|
71
|
+
const from = this.#state;
|
|
72
|
+
const to = pollTransition(from, event);
|
|
73
|
+
this.#context = pollContextTransition(this.#context, event);
|
|
74
|
+
if (to === from) return;
|
|
75
|
+
this.#state = to;
|
|
76
|
+
this.#hooks.onTransition?.(from, to, this.context);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Run until the flow resolves.
|
|
80
|
+
*
|
|
81
|
+
* Resolves rather than throws on every RFC-defined terminal state — denial
|
|
82
|
+
* and expiry are normal outcomes the UI must render, not exceptions.
|
|
83
|
+
*/
|
|
84
|
+
async run(signal) {
|
|
85
|
+
this.#send({ type: `START` });
|
|
86
|
+
while (!this.settled) {
|
|
87
|
+
if (signal?.aborted) {
|
|
88
|
+
this.#send({ type: `ABORT` });
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
if (this.#now() >= this.#context.deadline) {
|
|
92
|
+
this.#send({ type: `DEADLINE` });
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
await this.#sleep(this.#context.intervalSeconds * 1e3, signal);
|
|
96
|
+
if (signal?.aborted) {
|
|
97
|
+
this.#send({ type: `ABORT` });
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
this.#send({ type: `TICK` });
|
|
101
|
+
await this.#poll(signal);
|
|
102
|
+
}
|
|
103
|
+
return this.#outcome();
|
|
104
|
+
}
|
|
105
|
+
/** One request, mapped to an event. */
|
|
106
|
+
async #poll(signal) {
|
|
107
|
+
let body;
|
|
108
|
+
try {
|
|
109
|
+
body = await (await this.#fetch(this.#tokenUrl, {
|
|
110
|
+
method: `POST`,
|
|
111
|
+
headers: { "content-type": `application/x-www-form-urlencoded` },
|
|
112
|
+
body: new URLSearchParams({
|
|
113
|
+
grant_type: DEVICE_CODE_GRANT_TYPE,
|
|
114
|
+
device_code: this.#deviceCode,
|
|
115
|
+
...this.#clientId === void 0 ? {} : { client_id: this.#clientId }
|
|
116
|
+
}),
|
|
117
|
+
signal
|
|
118
|
+
})).json();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (signal?.aborted) {
|
|
121
|
+
this.#send({ type: `ABORT` });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.#send({ type: `NETWORK_ERROR` });
|
|
125
|
+
this.#hooks.onNetworkError?.(error, this.context);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (typeof body !== `object` || body === null) {
|
|
129
|
+
this.#send({ type: `AUTHORIZATION_PENDING` });
|
|
130
|
+
this.#hooks.onPending?.(this.context);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const response = { ...body };
|
|
134
|
+
const event = eventForTokenError(errorCode(body));
|
|
135
|
+
if (event.type === `SUCCESS`) this.#tokens = response;
|
|
136
|
+
this.#send(event);
|
|
137
|
+
if (event.type === `AUTHORIZATION_PENDING`) this.#hooks.onPending?.(this.context);
|
|
138
|
+
else if (event.type === `SLOW_DOWN`) this.#hooks.onSlowDown?.(this.#context.intervalSeconds, this.context);
|
|
139
|
+
}
|
|
140
|
+
#outcome() {
|
|
141
|
+
switch (this.#state) {
|
|
142
|
+
case `authorized`: return {
|
|
143
|
+
status: `authorized`,
|
|
144
|
+
tokens: this.#tokens ?? {}
|
|
145
|
+
};
|
|
146
|
+
case `denied`: return { status: `denied` };
|
|
147
|
+
case `expired`: return { status: `expired` };
|
|
148
|
+
case `aborted`: return { status: `aborted` };
|
|
149
|
+
case `idle`:
|
|
150
|
+
case `waiting`:
|
|
151
|
+
case `polling`: return { status: `aborted` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Poll until the flow resolves.
|
|
157
|
+
*
|
|
158
|
+
* Function form of {@link DeviceAuthClient} for callers who do not need to
|
|
159
|
+
* observe state — the common case on a TV screen that only cares about the
|
|
160
|
+
* final outcome.
|
|
161
|
+
*/
|
|
162
|
+
const pollUntilAuthorized = async ({ signal, onPending, onSlowDown, ...options }) => new DeviceAuthClient({
|
|
163
|
+
...options,
|
|
164
|
+
hooks: {
|
|
165
|
+
...options.hooks,
|
|
166
|
+
onPending: (context) => {
|
|
167
|
+
onPending?.();
|
|
168
|
+
options.hooks?.onPending?.(context);
|
|
169
|
+
},
|
|
170
|
+
onSlowDown: (interval, context) => {
|
|
171
|
+
onSlowDown?.(interval);
|
|
172
|
+
options.hooks?.onSlowDown?.(interval, context);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}).run(signal);
|
|
176
|
+
/**
|
|
177
|
+
* Read the `error` field of a token response.
|
|
178
|
+
*
|
|
179
|
+
* Returns `undefined` only when the field is genuinely absent — that is the
|
|
180
|
+
* success signal. A present-but-non-string value is reported as a placeholder
|
|
181
|
+
* so it maps to the unknown-code branch and keeps polling, rather than being
|
|
182
|
+
* mistaken for success.
|
|
183
|
+
*/
|
|
184
|
+
const errorCode = (body) => {
|
|
185
|
+
if (!(`error` in body)) return void 0;
|
|
186
|
+
return typeof body.error === `string` ? body.error : `malformed_error`;
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Narrow an arbitrary JSON body to a device-authorization response.
|
|
190
|
+
*
|
|
191
|
+
* A predicate rather than a cast: the two fields the device screen cannot
|
|
192
|
+
* render without are actually checked, so the narrowing is proven rather than
|
|
193
|
+
* asserted. The optional fields are left unverified — a missing `interval` has
|
|
194
|
+
* a spec default, and a missing `verification_uri_complete` only costs the QR.
|
|
195
|
+
*/
|
|
196
|
+
const isDeviceAuthorizationResponse = (body) => typeof body === `object` && body !== null && `device_code` in body && typeof body.device_code === `string` && `user_code` in body && typeof body.user_code === `string`;
|
|
197
|
+
/** Convenience: start a flow against the host's device-authorization endpoint. */
|
|
198
|
+
const requestDeviceAuthorization = async ({ authorizationUrl, clientId, scope, fetchImpl = fetch }) => {
|
|
199
|
+
const res = await fetchImpl(authorizationUrl, {
|
|
200
|
+
method: `POST`,
|
|
201
|
+
headers: { "content-type": `application/x-www-form-urlencoded` },
|
|
202
|
+
body: new URLSearchParams({
|
|
203
|
+
...clientId === void 0 ? {} : { client_id: clientId },
|
|
204
|
+
...scope === void 0 ? {} : { scope }
|
|
205
|
+
})
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) throw new Error(`device authorization failed: ${res.status}`);
|
|
208
|
+
const body = await res.json();
|
|
209
|
+
if (!isDeviceAuthorizationResponse(body)) throw new Error(`device authorization returned an unexpected body`);
|
|
210
|
+
return body;
|
|
211
|
+
};
|
|
212
|
+
//#endregion
|
|
213
|
+
export { DeviceAuthClient, pollUntilAuthorized, requestDeviceAuthorization, sleep };
|
|
214
|
+
|
|
215
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["#tokenUrl","#deviceCode","#clientId","#hooks","#fetch","#now","#sleep","#context","#state","#send","#poll","#outcome","#tokens"],"sources":["../../src/client/index.ts"],"sourcesContent":["/**\n * Device-side polling loop (RFC 8628).\n *\n * Runs on the constrained device (TV, kiosk, Pi). Deliberately dependency-free\n * and DOM-free so it works in a Fire OS WebView, a browser, or Node.\n *\n * Transport is polling only, per the spec. No SSE, no WebSocket: this screen\n * may stay powered on for days, and a persistent connection is one more thing\n * to leak, reconnect, and debug on hardware we cannot attach a profiler to.\n *\n * The loop is a state machine (`machine.ts`) wrapped in a class that owns the\n * state and the in-flight `device_code`. Hooks let a UI render every transition\n * without reaching into that state.\n */\n\nimport {\n eventForTokenError,\n isPollSettled,\n pollContextTransition,\n pollTransition,\n type PollContext,\n type PollEvent,\n type PollState\n} from \"../machine.js\";\nimport { DEVICE_CODE_GRANT_TYPE } from \"../types.js\";\nimport type {\n DeviceAuthorizationError,\n DeviceAuthorizationResponse\n} from \"../types.js\";\n\n/** What the host's token endpoint returns. Mirrors RFC 8628 §3.5. */\nexport interface TokenEndpointResponse {\n error?: DeviceAuthorizationError;\n [key: string]: unknown;\n}\n\n/** Observers of the poll loop. All optional, all fire after the move. */\nexport interface PollHooks {\n /** Any successful transition, with the context that produced it. */\n onTransition?: (from: PollState, to: PollState, context: PollContext) => void;\n /** A poll returned `authorization_pending`. Good place for a \"still waiting\" cue. */\n onPending?: (context: PollContext) => void;\n /**\n * The server asked us to slow down. Receives the NEW interval, so a UI can\n * show the changed cadence rather than a stale one.\n */\n onSlowDown?: (intervalSeconds: number, context: PollContext) => void;\n /** A request failed at the network level. The flow continues. */\n onNetworkError?: (error: unknown, context: PollContext) => void;\n}\n\n/**\n * Cancellable delay. Exported so callers (and tests) reuse the same abort-aware\n * behavior the loop relies on rather than reimplementing it.\n */\nexport const sleep = async (\n ms: number,\n signal?: AbortSignal\n): Promise<void> => {\n if (signal?.aborted) return;\n // The timer and the abort listener race. Whichever fires first cancels the\n // other. Without the abort path the loop finishes a full interval before\n // noticing it was cancelled — a visibly stuck UI on a long cadence.\n await new Promise<void>((resolve) => {\n const finish = (): void => {\n clearTimeout(timer);\n signal?.removeEventListener(`abort`, finish);\n // oxlint-disable-next-line promise/no-multiple-resolved\n resolve();\n };\n const timer = setTimeout(finish, ms);\n signal?.addEventListener(`abort`, finish, { once: true });\n });\n};\n\nexport interface DeviceAuthClientOptions {\n /** Token endpoint URL. */\n tokenUrl: string;\n /** From the authorization response. */\n deviceCode: string;\n /** Seconds between polls, from the authorization response. */\n interval: number;\n /** Seconds until both codes die, from the authorization response. */\n expiresIn: number;\n /** Sent as `client_id` when the host app tracks clients. */\n clientId?: string;\n /** Lifecycle observers. */\n hooks?: PollHooks;\n /** Injectable for tests. */\n fetchImpl?: typeof fetch;\n /** Injectable clock, for tests. */\n now?: () => number;\n /**\n * Injectable delay, for tests.\n *\n * Exposed so the growing intervals (`slow_down`, network backoff) can be\n * asserted without a suite that actually waits minutes for them.\n */\n sleepImpl?: (ms: number, signal?: AbortSignal) => Promise<void>;\n}\n\n/** Terminal outcomes of the loop. */\nexport type AuthorizationOutcome =\n | { status: `authorized`; tokens: TokenEndpointResponse }\n | { status: `denied` }\n | { status: `expired` }\n | { status: `aborted` };\n\nexport class DeviceAuthClient {\n readonly #tokenUrl: string;\n readonly #deviceCode: string;\n readonly #clientId: string | undefined;\n readonly #hooks: PollHooks;\n readonly #fetch: typeof fetch;\n readonly #now: () => number;\n readonly #sleep: (ms: number, signal?: AbortSignal) => Promise<void>;\n\n #state: PollState = `idle`;\n #context: PollContext;\n #tokens: TokenEndpointResponse | undefined;\n\n constructor({\n tokenUrl,\n deviceCode,\n interval,\n expiresIn,\n clientId,\n hooks = {},\n fetchImpl = fetch,\n now = (): number => Date.now(),\n sleepImpl = sleep\n }: DeviceAuthClientOptions) {\n this.#tokenUrl = tokenUrl;\n this.#deviceCode = deviceCode;\n this.#clientId = clientId;\n this.#hooks = hooks;\n this.#fetch = fetchImpl;\n this.#now = now;\n this.#sleep = sleepImpl;\n this.#context = {\n intervalSeconds: interval,\n deadline: now() + expiresIn * 1000,\n attempts: 0\n };\n }\n\n get state(): PollState {\n return this.#state;\n }\n\n /** Snapshot of the extended state — interval, deadline, attempts. */\n get context(): Readonly<PollContext> {\n return { ...this.#context };\n }\n\n get settled(): boolean {\n return isPollSettled(this.#state);\n }\n\n /** Apply an event to both reducers, then fire hooks. */\n #send(event: PollEvent): void {\n const from = this.#state;\n const to = pollTransition(from, event);\n this.#context = pollContextTransition(this.#context, event);\n\n if (to === from) return;\n this.#state = to;\n this.#hooks.onTransition?.(from, to, this.context);\n }\n\n /**\n * Run until the flow resolves.\n *\n * Resolves rather than throws on every RFC-defined terminal state — denial\n * and expiry are normal outcomes the UI must render, not exceptions.\n */\n async run(signal?: AbortSignal): Promise<AuthorizationOutcome> {\n this.#send({ type: `START` });\n\n while (!this.settled) {\n if (signal?.aborted) {\n this.#send({ type: `ABORT` });\n break;\n }\n\n // Stop on our own deadline even if the server never says `expired_token`\n // — a device that polls a dead code forever is a support call.\n if (this.#now() >= this.#context.deadline) {\n this.#send({ type: `DEADLINE` });\n break;\n }\n\n await this.#sleep(this.#context.intervalSeconds * 1000, signal);\n if (signal?.aborted) {\n this.#send({ type: `ABORT` });\n break;\n }\n\n this.#send({ type: `TICK` });\n await this.#poll(signal);\n }\n\n return this.#outcome();\n }\n\n /** One request, mapped to an event. */\n async #poll(signal?: AbortSignal): Promise<void> {\n let body: unknown;\n try {\n const res = await this.#fetch(this.#tokenUrl, {\n method: `POST`,\n headers: { \"content-type\": `application/x-www-form-urlencoded` },\n body: new URLSearchParams({\n grant_type: DEVICE_CODE_GRANT_TYPE,\n device_code: this.#deviceCode,\n ...(this.#clientId === undefined ? {} : { client_id: this.#clientId })\n }),\n signal\n });\n body = await res.json();\n } catch (error) {\n if (signal?.aborted) {\n this.#send({ type: `ABORT` });\n return;\n }\n // Venue wifi drops. A blip must not look like denial — the user has done\n // nothing wrong and the code is still valid. The context reducer applies\n // exponential backoff here, unlike `slow_down`'s fixed +5s.\n this.#send({ type: `NETWORK_ERROR` });\n this.#hooks.onNetworkError?.(error, this.context);\n return;\n }\n\n // A non-object body cannot be a token response. Retrying is the safe\n // reading: treating it as success would sign the device in on garbage, and\n // treating it as denial would blame the user for a broken endpoint.\n if (typeof body !== `object` || body === null) {\n this.#send({ type: `AUTHORIZATION_PENDING` });\n this.#hooks.onPending?.(this.context);\n return;\n }\n\n const response: TokenEndpointResponse = { ...body };\n // Only `error` is interpreted, and `errorCode` distinguishes \"absent\"\n // (success) from \"present but not a string\" (malformed). Collapsing the\n // second into the first would read a broken response as success and sign\n // the device in on garbage.\n const event = eventForTokenError(errorCode(body));\n\n // Captured before the transition so the terminal outcome can return it;\n // the body is the host app's credential payload, passed through\n // uninterpreted.\n if (event.type === `SUCCESS`) this.#tokens = response;\n\n this.#send(event);\n\n if (event.type === `AUTHORIZATION_PENDING`) {\n this.#hooks.onPending?.(this.context);\n } else if (event.type === `SLOW_DOWN`) {\n this.#hooks.onSlowDown?.(this.#context.intervalSeconds, this.context);\n }\n }\n\n #outcome(): AuthorizationOutcome {\n switch (this.#state) {\n case `authorized`:\n return { status: `authorized`, tokens: this.#tokens ?? {} };\n case `denied`:\n return { status: `denied` };\n case `expired`:\n return { status: `expired` };\n case `aborted`:\n return { status: `aborted` };\n // `run` only calls this once the machine has settled, so the live states\n // are unreachable. Reported as aborted rather than thrown: a UI showing\n // \"cancelled\" is a better failure than a crashed sign-in screen.\n case `idle`:\n case `waiting`:\n case `polling`:\n return { status: `aborted` };\n }\n }\n}\n\n/**\n * Poll until the flow resolves.\n *\n * Function form of {@link DeviceAuthClient} for callers who do not need to\n * observe state — the common case on a TV screen that only cares about the\n * final outcome.\n */\nexport const pollUntilAuthorized = async ({\n signal,\n onPending,\n onSlowDown,\n ...options\n}: DeviceAuthClientOptions & {\n signal?: AbortSignal;\n /** Convenience passthroughs, so existing callers keep working. */\n onPending?: () => void;\n onSlowDown?: (nextIntervalSeconds: number) => void;\n}): Promise<AuthorizationOutcome> =>\n new DeviceAuthClient({\n ...options,\n hooks: {\n ...options.hooks,\n onPending: (context) => {\n onPending?.();\n options.hooks?.onPending?.(context);\n },\n onSlowDown: (interval, context) => {\n onSlowDown?.(interval);\n options.hooks?.onSlowDown?.(interval, context);\n }\n }\n }).run(signal);\n\n/**\n * Read the `error` field of a token response.\n *\n * Returns `undefined` only when the field is genuinely absent — that is the\n * success signal. A present-but-non-string value is reported as a placeholder\n * so it maps to the unknown-code branch and keeps polling, rather than being\n * mistaken for success.\n */\nconst errorCode = (body: object): string | undefined => {\n if (!(`error` in body)) return undefined;\n return typeof body.error === `string` ? body.error : `malformed_error`;\n};\n\n/**\n * Narrow an arbitrary JSON body to a device-authorization response.\n *\n * A predicate rather than a cast: the two fields the device screen cannot\n * render without are actually checked, so the narrowing is proven rather than\n * asserted. The optional fields are left unverified — a missing `interval` has\n * a spec default, and a missing `verification_uri_complete` only costs the QR.\n */\nconst isDeviceAuthorizationResponse = (\n body: unknown\n): body is DeviceAuthorizationResponse =>\n typeof body === `object` &&\n body !== null &&\n `device_code` in body &&\n typeof body.device_code === `string` &&\n `user_code` in body &&\n typeof body.user_code === `string`;\n\n/** Convenience: start a flow against the host's device-authorization endpoint. */\nexport const requestDeviceAuthorization = async ({\n authorizationUrl,\n clientId,\n scope,\n fetchImpl = fetch\n}: {\n authorizationUrl: string;\n clientId?: string;\n scope?: string;\n fetchImpl?: typeof fetch;\n}): Promise<DeviceAuthorizationResponse> => {\n const res = await fetchImpl(authorizationUrl, {\n method: `POST`,\n headers: { \"content-type\": `application/x-www-form-urlencoded` },\n body: new URLSearchParams({\n ...(clientId === undefined ? {} : { client_id: clientId }),\n ...(scope === undefined ? {} : { scope })\n })\n });\n if (!res.ok) throw new Error(`device authorization failed: ${res.status}`);\n\n const body: unknown = await res.json();\n // Fail loud at the boundary (CLAUDE.md Rule 11). Without the required fields\n // the device screen would render a blank code and an unscannable QR, with no\n // hint as to why — far worse than an error naming the bad response.\n if (!isDeviceAuthorizationResponse(body)) {\n throw new Error(`device authorization returned an unexpected body`);\n }\n return body;\n};\n"],"mappings":"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"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//#region src/codes.ts
|
|
2
|
+
/**
|
|
3
|
+
* Code generation.
|
|
4
|
+
*
|
|
5
|
+
* Two codes with opposite constraints:
|
|
6
|
+
*
|
|
7
|
+
* - `user_code` is read off a TV across a room and typed on a phone, so it must
|
|
8
|
+
* be SHORT. That caps its entropy, which is why RFC 8628 §5.1 requires the
|
|
9
|
+
* server to rate-limit attempts — the code alone is not brute-force safe.
|
|
10
|
+
* - `device_code` is never displayed, so it has no usability ceiling. The spec
|
|
11
|
+
* says "a very high entropy code SHOULD be used".
|
|
12
|
+
*
|
|
13
|
+
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-5.1
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* RFC 8628 §6.1's recommended base-20 alphabet: consonants only.
|
|
17
|
+
*
|
|
18
|
+
* No vowels, so generated codes cannot accidentally spell words. No digits, so
|
|
19
|
+
* there is no 0/O or 1/l/I confusion. 20^8 ≈ 34.5 bits at 8 characters, which
|
|
20
|
+
* the spec pairs with a 5-attempt rate limit.
|
|
21
|
+
*/
|
|
22
|
+
const BASE20_ALPHABET = `BCDFGHJKLMNPQRSTVWXZ`;
|
|
23
|
+
/**
|
|
24
|
+
* Digits only — better for non-Latin locales and numeric TV remotes, which is
|
|
25
|
+
* why Plex-style flows often use them. Lower entropy per character than base-20
|
|
26
|
+
* (10 vs 20), so prefer a longer code when using this.
|
|
27
|
+
*/
|
|
28
|
+
const NUMERIC_ALPHABET = `0123456789`;
|
|
29
|
+
/** Bytes of entropy for `device_code`. 32 bytes = 256 bits. */
|
|
30
|
+
const DEVICE_CODE_BYTES = 32;
|
|
31
|
+
/**
|
|
32
|
+
* Rejection-sampled index into `alphabet`.
|
|
33
|
+
*
|
|
34
|
+
* A naive `byte % alphabet.length` is biased whenever 256 is not a multiple of
|
|
35
|
+
* the alphabet size (it is not, for 20): low indices would come up more often.
|
|
36
|
+
* We discard bytes above the largest clean multiple instead. This matters — a
|
|
37
|
+
* skewed distribution shrinks the effective keyspace an attacker must search.
|
|
38
|
+
*/
|
|
39
|
+
const unbiasedIndex = (alphabet) => {
|
|
40
|
+
const limit = Math.floor(256 / alphabet.length) * alphabet.length;
|
|
41
|
+
const buf = /* @__PURE__ */ new Uint8Array(1);
|
|
42
|
+
let byte;
|
|
43
|
+
do {
|
|
44
|
+
crypto.getRandomValues(buf);
|
|
45
|
+
byte = buf[0];
|
|
46
|
+
} while (byte >= limit);
|
|
47
|
+
return byte % alphabet.length;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Generate a user-facing code, formatted for display.
|
|
51
|
+
*
|
|
52
|
+
* Defaults follow the spec's worked example: 8 base-20 characters shown as
|
|
53
|
+
* `WDJB-MJHT`.
|
|
54
|
+
*/
|
|
55
|
+
const generateUserCode = ({ length = 8, alphabet = BASE20_ALPHABET, separator = `-`, groupSize = 4 } = {}) => {
|
|
56
|
+
if (length < 1) throw new RangeError(`user code length must be >= 1`);
|
|
57
|
+
if (alphabet.length < 2) throw new RangeError(`alphabet needs >= 2 characters`);
|
|
58
|
+
let code = ``;
|
|
59
|
+
for (let i = 0; i < length; i++) code += alphabet[unbiasedIndex(alphabet)];
|
|
60
|
+
if (!separator || groupSize < 1) return code;
|
|
61
|
+
const groups = [];
|
|
62
|
+
for (let i = 0; i < code.length; i += groupSize) groups.push(code.slice(i, i + groupSize));
|
|
63
|
+
return groups.join(separator);
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Canonicalize user input before comparison.
|
|
67
|
+
*
|
|
68
|
+
* RFC 8628 §6.1: the server strips punctuation it added for readability, and
|
|
69
|
+
* uppercases A-Z codes. Without this, a user typing `wdjb mjht` — which is what
|
|
70
|
+
* a phone keyboard will autocorrect toward — fails against `WDJB-MJHT` for no
|
|
71
|
+
* reason the user can see.
|
|
72
|
+
*
|
|
73
|
+
* Strips ALL non-alphanumerics, so it is agnostic to whichever separator the
|
|
74
|
+
* display format used.
|
|
75
|
+
*/
|
|
76
|
+
const normalizeUserCode = (input) => input.replace(/[^a-zA-Z0-9]/gu, ``).toUpperCase();
|
|
77
|
+
/**
|
|
78
|
+
* Generate a `device_code`: 256 bits, base64url, no padding.
|
|
79
|
+
*
|
|
80
|
+
* base64url (not base64) because this value travels in URLs and form bodies,
|
|
81
|
+
* where `+` and `/` would need escaping.
|
|
82
|
+
*/
|
|
83
|
+
const generateDeviceCode = () => {
|
|
84
|
+
const bytes = new Uint8Array(DEVICE_CODE_BYTES);
|
|
85
|
+
crypto.getRandomValues(bytes);
|
|
86
|
+
let binary = ``;
|
|
87
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
88
|
+
return btoa(binary).replace(/\+/gu, `-`).replace(/\//gu, `_`).replace(/=+$/u, ``);
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { normalizeUserCode as a, generateUserCode as i, NUMERIC_ALPHABET as n, generateDeviceCode as r, BASE20_ALPHABET as t };
|
|
92
|
+
|
|
93
|
+
//# sourceMappingURL=codes-Ba_qYH6u.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codes-Ba_qYH6u.mjs","names":[],"sources":["../src/codes.ts"],"sourcesContent":["/**\n * Code generation.\n *\n * Two codes with opposite constraints:\n *\n * - `user_code` is read off a TV across a room and typed on a phone, so it must\n * be SHORT. That caps its entropy, which is why RFC 8628 §5.1 requires the\n * server to rate-limit attempts — the code alone is not brute-force safe.\n * - `device_code` is never displayed, so it has no usability ceiling. The spec\n * says \"a very high entropy code SHOULD be used\".\n *\n * @see https://datatracker.ietf.org/doc/html/rfc8628#section-5.1\n */\n\n/**\n * RFC 8628 §6.1's recommended base-20 alphabet: consonants only.\n *\n * No vowels, so generated codes cannot accidentally spell words. No digits, so\n * there is no 0/O or 1/l/I confusion. 20^8 ≈ 34.5 bits at 8 characters, which\n * the spec pairs with a 5-attempt rate limit.\n */\nexport const BASE20_ALPHABET = `BCDFGHJKLMNPQRSTVWXZ`;\n\n/**\n * Digits only — better for non-Latin locales and numeric TV remotes, which is\n * why Plex-style flows often use them. Lower entropy per character than base-20\n * (10 vs 20), so prefer a longer code when using this.\n */\nexport const NUMERIC_ALPHABET = `0123456789`;\n\n/** Bytes of entropy for `device_code`. 32 bytes = 256 bits. */\nconst DEVICE_CODE_BYTES = 32;\n\n/**\n * Rejection-sampled index into `alphabet`.\n *\n * A naive `byte % alphabet.length` is biased whenever 256 is not a multiple of\n * the alphabet size (it is not, for 20): low indices would come up more often.\n * We discard bytes above the largest clean multiple instead. This matters — a\n * skewed distribution shrinks the effective keyspace an attacker must search.\n */\nconst unbiasedIndex = (alphabet: string): number => {\n const limit = Math.floor(256 / alphabet.length) * alphabet.length;\n const buf = new Uint8Array(1);\n let byte: number;\n do {\n crypto.getRandomValues(buf);\n byte = buf[0]!;\n } while (byte >= limit);\n return byte % alphabet.length;\n};\n\nexport interface UserCodeOptions {\n /** Significant characters, excluding any separator. Spec example uses 8. */\n length?: number;\n /** Character set to draw from. Defaults to {@link BASE20_ALPHABET}. */\n alphabet?: string;\n /**\n * Inserted every `groupSize` characters purely for legibility.\n * The stored/compared form never contains it — see {@link normalizeUserCode}.\n */\n separator?: string;\n /** Characters per visual group. Ignored when `separator` is empty. */\n groupSize?: number;\n}\n\n/**\n * Generate a user-facing code, formatted for display.\n *\n * Defaults follow the spec's worked example: 8 base-20 characters shown as\n * `WDJB-MJHT`.\n */\nexport const generateUserCode = ({\n length = 8,\n alphabet = BASE20_ALPHABET,\n separator = `-`,\n groupSize = 4\n}: UserCodeOptions = {}): string => {\n if (length < 1) throw new RangeError(`user code length must be >= 1`);\n if (alphabet.length < 2)\n throw new RangeError(`alphabet needs >= 2 characters`);\n\n let code = ``;\n for (let i = 0; i < length; i++) code += alphabet[unbiasedIndex(alphabet)];\n\n if (!separator || groupSize < 1) return code;\n\n const groups: string[] = [];\n for (let i = 0; i < code.length; i += groupSize) {\n groups.push(code.slice(i, i + groupSize));\n }\n return groups.join(separator);\n};\n\n/**\n * Canonicalize user input before comparison.\n *\n * RFC 8628 §6.1: the server strips punctuation it added for readability, and\n * uppercases A-Z codes. Without this, a user typing `wdjb mjht` — which is what\n * a phone keyboard will autocorrect toward — fails against `WDJB-MJHT` for no\n * reason the user can see.\n *\n * Strips ALL non-alphanumerics, so it is agnostic to whichever separator the\n * display format used.\n */\nexport const normalizeUserCode = (input: string): string =>\n input.replace(/[^a-zA-Z0-9]/gu, ``).toUpperCase();\n\n/**\n * Generate a `device_code`: 256 bits, base64url, no padding.\n *\n * base64url (not base64) because this value travels in URLs and form bodies,\n * where `+` and `/` would need escaping.\n */\nexport const generateDeviceCode = (): string => {\n const bytes = new Uint8Array(DEVICE_CODE_BYTES);\n crypto.getRandomValues(bytes);\n let binary = ``;\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary)\n .replace(/\\+/gu, `-`)\n .replace(/\\//gu, `_`)\n .replace(/=+$/u, ``);\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,kBAAkB;;;;;;AAO/B,MAAa,mBAAmB;;AAGhC,MAAM,oBAAoB;;;;;;;;;AAU1B,MAAM,iBAAiB,aAA6B;CAClD,MAAM,QAAQ,KAAK,MAAM,MAAM,SAAS,MAAM,IAAI,SAAS;CAC3D,MAAM,sBAAM,IAAI,WAAW,CAAC;CAC5B,IAAI;CACJ,GAAG;EACD,OAAO,gBAAgB,GAAG;EAC1B,OAAO,IAAI;CACb,SAAS,QAAQ;CACjB,OAAO,OAAO,SAAS;AACzB;;;;;;;AAsBA,MAAa,oBAAoB,EAC/B,SAAS,GACT,WAAW,iBACX,YAAY,KACZ,YAAY,MACO,CAAC,MAAc;CAClC,IAAI,SAAS,GAAG,MAAM,IAAI,WAAW,+BAA+B;CACpE,IAAI,SAAS,SAAS,GACpB,MAAM,IAAI,WAAW,gCAAgC;CAEvD,IAAI,OAAO;CACX,KAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,KAAK,QAAQ,SAAS,cAAc,QAAQ;CAExE,IAAI,CAAC,aAAa,YAAY,GAAG,OAAO;CAExC,MAAM,SAAmB,CAAC;CAC1B,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK,WACpC,OAAO,KAAK,KAAK,MAAM,GAAG,IAAI,SAAS,CAAC;CAE1C,OAAO,OAAO,KAAK,SAAS;AAC9B;;;;;;;;;;;;AAaA,MAAa,qBAAqB,UAChC,MAAM,QAAQ,kBAAkB,EAAE,CAAC,CAAC,YAAY;;;;;;;AAQlD,MAAa,2BAAmC;CAC9C,MAAM,QAAQ,IAAI,WAAW,iBAAiB;CAC9C,OAAO,gBAAgB,KAAK;CAC5B,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAChB,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,QAAQ,EAAE;AACvB"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { n as HankoServer } from "./server-BhoYRkCm.mjs";
|
|
2
|
+
//#region src/handlers.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Hook for the rate limiting RFC 8628 §5.1 requires.
|
|
5
|
+
*
|
|
6
|
+
* hanko deliberately does not implement it: an effective limiter needs the IP,
|
|
7
|
+
* which lives in a platform-specific header (`CF-Connecting-IP`,
|
|
8
|
+
* `x-forwarded-for`), and needs storage this library should not assume. What
|
|
9
|
+
* it can do is make the seam explicit so the requirement is not silently
|
|
10
|
+
* skipped.
|
|
11
|
+
*
|
|
12
|
+
* Return `false` to reject with 429.
|
|
13
|
+
*/
|
|
14
|
+
type RateLimiter = (request: Request, userCode: string) => Promise<boolean> | boolean;
|
|
15
|
+
interface HandlerOptions {
|
|
16
|
+
server: HankoServer;
|
|
17
|
+
/**
|
|
18
|
+
* Identify the approving user from their session.
|
|
19
|
+
*
|
|
20
|
+
* The trust boundary of the whole flow: whatever this returns becomes the
|
|
21
|
+
* `subject` the device is signed in as. Read it from YOUR session — never
|
|
22
|
+
* from the request body, which the client controls.
|
|
23
|
+
*
|
|
24
|
+
* Return `null` when unauthenticated, and the approval endpoint answers 401.
|
|
25
|
+
*/
|
|
26
|
+
authenticate: (request: Request) => Promise<string | null> | string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Guard the approval endpoint. Strongly recommended — see {@link RateLimiter}.
|
|
29
|
+
*/
|
|
30
|
+
rateLimit?: RateLimiter;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The device-authorization endpoint. `POST /device/authorize`.
|
|
34
|
+
*
|
|
35
|
+
* Unauthenticated: the whole point is that the device has no credentials yet.
|
|
36
|
+
*/
|
|
37
|
+
declare const createAuthorizationHandler: ({ server, verificationPath, trustForwardedHost }: Pick<HandlerOptions, `server`> & {
|
|
38
|
+
/** Path of the approval page, appended to the detected origin. */
|
|
39
|
+
verificationPath?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Derive the verification URI from the request's forwarded headers.
|
|
42
|
+
*
|
|
43
|
+
* On by default because it is what makes one deployment work across a
|
|
44
|
+
* preview URL, a custom domain, and a tunnel without a redeploy. Set false
|
|
45
|
+
* to always use the configured `verificationUri` — worth doing if your
|
|
46
|
+
* platform does not strip client-sent `x-forwarded-*` headers and you would
|
|
47
|
+
* rather pin the origin than trust them.
|
|
48
|
+
*/
|
|
49
|
+
trustForwardedHost?: boolean;
|
|
50
|
+
}) => (request: Request) => Promise<Response>;
|
|
51
|
+
/**
|
|
52
|
+
* The token endpoint. `POST /device/token`.
|
|
53
|
+
*
|
|
54
|
+
* Maps poll results onto the status codes RFC 8628 §3.5 specifies: pending and
|
|
55
|
+
* slow_down are 400s carrying an error code, not 200s, because a compliant
|
|
56
|
+
* client distinguishes them by body rather than status.
|
|
57
|
+
*/
|
|
58
|
+
declare const createTokenHandler: ({ server, createSession }: Pick<HandlerOptions, `server`> & {
|
|
59
|
+
/**
|
|
60
|
+
* Mint whatever credential the device should receive.
|
|
61
|
+
*
|
|
62
|
+
* hanko carries the `subject` and stops there — issuing sessions is your
|
|
63
|
+
* auth system's job, and duplicating it would make this library compete
|
|
64
|
+
* with Better-Auth instead of composing with it.
|
|
65
|
+
*/
|
|
66
|
+
createSession: (subject: string) => unknown;
|
|
67
|
+
}) => (request: Request) => Promise<Response>;
|
|
68
|
+
/**
|
|
69
|
+
* The approval endpoint. `GET` to resolve a code, `POST` to decide.
|
|
70
|
+
*
|
|
71
|
+
* Both require an authenticated user: this runs on the phone that is already
|
|
72
|
+
* signed in, and the identity it resolves is what the device inherits.
|
|
73
|
+
*/
|
|
74
|
+
declare const createApprovalHandler: ({ server, authenticate, rateLimit }: HandlerOptions) => (request: Request) => Promise<Response>;
|
|
75
|
+
/**
|
|
76
|
+
* All three handlers, plus a router for hosts that prefer one entry point.
|
|
77
|
+
*
|
|
78
|
+
* The router is a convenience — a Workers `fetch` can delegate to it wholesale
|
|
79
|
+
* — but the individual handlers exist so file-based routing (Astro, Next,
|
|
80
|
+
* SvelteKit) can mount each at its own path.
|
|
81
|
+
*/
|
|
82
|
+
declare const createHandlers: (options: HandlerOptions & {
|
|
83
|
+
createSession: (subject: string) => unknown;
|
|
84
|
+
}) => {
|
|
85
|
+
authorize: (request: Request) => Promise<Response>;
|
|
86
|
+
token: (request: Request) => Promise<Response>;
|
|
87
|
+
approval: (request: Request) => Promise<Response>;
|
|
88
|
+
fetch: (request: Request) => Promise<Response>;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Serve the association files that make universal/app links work.
|
|
92
|
+
*
|
|
93
|
+
* Both must be served from the SAME origin as the approval page, over HTTPS,
|
|
94
|
+
* with no redirects. Apple and Google fetch them directly; a redirect or a
|
|
95
|
+
* wrong content-type makes the association fail silently, which is the usual
|
|
96
|
+
* reason "universal links don't work" with nothing in any log to explain it.
|
|
97
|
+
*
|
|
98
|
+
* Mount at `/.well-known/*`. Cached rather than `no-store` — unlike the auth
|
|
99
|
+
* endpoints these are static, and the platforms re-fetch them on their own
|
|
100
|
+
* schedule anyway.
|
|
101
|
+
*/
|
|
102
|
+
declare const createWellKnownHandler: ({ appleAppIds, androidPackageName, androidFingerprints, paths }: {
|
|
103
|
+
/** `<TEAM_ID>.<BUNDLE_ID>` for each iOS app that may open these links. */
|
|
104
|
+
appleAppIds?: string[];
|
|
105
|
+
androidPackageName?: string;
|
|
106
|
+
/** SHA-256 of the PLAY-signed certificate, not the local keystore. */
|
|
107
|
+
androidFingerprints?: string[];
|
|
108
|
+
/** Paths the apps claim. Defaults to the approval route. */
|
|
109
|
+
paths?: string[];
|
|
110
|
+
}) => (request: Request) => Response | null;
|
|
111
|
+
//#endregion
|
|
112
|
+
export { HandlerOptions, RateLimiter, createApprovalHandler, createAuthorizationHandler, createHandlers, createTokenHandler, createWellKnownHandler };
|
|
113
|
+
//# sourceMappingURL=handlers.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handlers.d.mts","names":[],"sources":["../src/handlers.ts"],"mappings":";;;;;;;;;;;;;KAuEY,eACV,SAAS,SACT,qBACG;UAEY;EACf,QAAQ;;;;;;;;;;EAUR,eAAe,SAAS,YAAY;;;;EAIpC,YAAY;;;;;;;cAsCD,+BACV,QAAA,kBAAA,sBAIE,KAAK;;EAEN;;;;;;;;;;EAUA;OAEK,SAAS,YAAU,QAAQ;;;;;;;;cAwBvB,uBACV,QAAA,iBAGE,KAAK;;;;;;;;EAQN,gBAAgB;OAEX,SAAS,YAAU,QAAQ;;;;;;;cA+BvB,0BACV,QAAA,cAAA,aAAqC,oBAC/B,SAAS,YAAU,QAAQ;;;;;;;;cAwEvB,iBACX,SAAS;EACP,gBAAgB;;EAGlB,YAAY,SAAS,YAAY,QAAQ;EACzC,QAAQ,SAAS,YAAY,QAAQ;EACrC,WAAW,SAAS,YAAY,QAAQ;EACxC,QAAQ,SAAS,YAAY,QAAQ;;;;;;;;;;;;;;cAgC1B,2BAA0B,aAAA,oBAAA,qBAAA;;EAOrC;EACA;;EAEA;;EAEA;OAUQ,SAAS,YAAU"}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { a as digitalAssetLinks, t as appleAppSiteAssociation } from "./linking-DcQSMgem.mjs";
|
|
2
|
+
//#region src/handlers.ts
|
|
3
|
+
/**
|
|
4
|
+
* Request → Response glue for the three endpoints the flow needs.
|
|
5
|
+
*
|
|
6
|
+
* Built on the WinterTC `Request`/`Response` pair, so the same handlers run on
|
|
7
|
+
* Cloudflare Workers, Vercel Functions, Deno Deploy, Bun, and Node 18+ without
|
|
8
|
+
* a framework adapter. This is the layer hanko ships so a host app writes
|
|
9
|
+
* routing, not protocol.
|
|
10
|
+
*
|
|
11
|
+
* Stateless by construction: nothing is held between invocations. Every
|
|
12
|
+
* request loads its grant from the store, applies one transition, and writes
|
|
13
|
+
* it back — which is what makes this safe on an edge runtime where the next
|
|
14
|
+
* request may land on a different instance, or on an instance that was frozen
|
|
15
|
+
* mid-flow.
|
|
16
|
+
*/
|
|
17
|
+
/** JSON with a status. Kept local so nothing imports a framework helper. */
|
|
18
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
19
|
+
status,
|
|
20
|
+
headers: {
|
|
21
|
+
"content-type": `application/json`,
|
|
22
|
+
"cache-control": `no-store`
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Read parameters from a form body or JSON.
|
|
27
|
+
*
|
|
28
|
+
* RFC 8628 specifies form encoding, but hosts routinely post JSON from their
|
|
29
|
+
* own front end, and rejecting that would be pedantry rather than security.
|
|
30
|
+
*/
|
|
31
|
+
const readParams = async (request) => {
|
|
32
|
+
if ((request.headers.get(`content-type`) ?? ``).includes(`application/json`)) {
|
|
33
|
+
const body = await request.json();
|
|
34
|
+
if (typeof body !== `object` || body === null) return {};
|
|
35
|
+
return Object.fromEntries(Object.entries(body).map(([key, value]) => [key, String(value)]));
|
|
36
|
+
}
|
|
37
|
+
const params = {};
|
|
38
|
+
for (const [key, value] of new URLSearchParams(await request.text())) params[key] = value;
|
|
39
|
+
return params;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Public origin this request actually arrived on.
|
|
43
|
+
*
|
|
44
|
+
* `request.url` is unreliable behind a proxy: it carries the internal host the
|
|
45
|
+
* proxy forwarded to, not the one the client typed. `x-forwarded-host` and
|
|
46
|
+
* `x-forwarded-proto` carry the real ones, and every common proxy sets them —
|
|
47
|
+
* ngrok, Cloudflare, Vercel, nginx.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` when nothing usable is present, so the caller falls back to
|
|
50
|
+
* its configured value rather than guessing.
|
|
51
|
+
*
|
|
52
|
+
* These headers are CLIENT-CONTROLLABLE when no proxy strips them, so this is
|
|
53
|
+
* only safe for building a URL the same client will visit. Never use it for an
|
|
54
|
+
* authorization decision.
|
|
55
|
+
*/
|
|
56
|
+
const forwardedOrigin = (request) => {
|
|
57
|
+
const host = request.headers.get(`x-forwarded-host`);
|
|
58
|
+
if (host === null || host.length === 0) return null;
|
|
59
|
+
const [first] = host.split(`,`);
|
|
60
|
+
const cleaned = first.trim();
|
|
61
|
+
if (cleaned.length === 0) return null;
|
|
62
|
+
const proto = request.headers.get(`x-forwarded-proto`)?.split(`,`)[0]?.trim();
|
|
63
|
+
return `${proto === void 0 || proto.length === 0 ? `https` : proto}://${cleaned}`;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The device-authorization endpoint. `POST /device/authorize`.
|
|
67
|
+
*
|
|
68
|
+
* Unauthenticated: the whole point is that the device has no credentials yet.
|
|
69
|
+
*/
|
|
70
|
+
const createAuthorizationHandler = ({ server, verificationPath = `/link`, trustForwardedHost = true }) => async (request) => {
|
|
71
|
+
if (request.method !== `POST`) return json({ error: `method_not_allowed` }, 405);
|
|
72
|
+
const origin = trustForwardedHost ? forwardedOrigin(request) : null;
|
|
73
|
+
const params = await readParams(request);
|
|
74
|
+
const grant = await server.requestAuthorization({
|
|
75
|
+
clientId: params.client_id,
|
|
76
|
+
scope: params.scope,
|
|
77
|
+
verificationUri: origin === null ? void 0 : `${origin}${verificationPath}`
|
|
78
|
+
});
|
|
79
|
+
return json(grant);
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The token endpoint. `POST /device/token`.
|
|
83
|
+
*
|
|
84
|
+
* Maps poll results onto the status codes RFC 8628 §3.5 specifies: pending and
|
|
85
|
+
* slow_down are 400s carrying an error code, not 200s, because a compliant
|
|
86
|
+
* client distinguishes them by body rather than status.
|
|
87
|
+
*/
|
|
88
|
+
const createTokenHandler = ({ server, createSession }) => async (request) => {
|
|
89
|
+
if (request.method !== `POST`) return json({ error: `method_not_allowed` }, 405);
|
|
90
|
+
const deviceCode = (await readParams(request)).device_code;
|
|
91
|
+
if (deviceCode === void 0) return json({ error: `invalid_request` }, 400);
|
|
92
|
+
const result = await server.poll(deviceCode);
|
|
93
|
+
switch (result.status) {
|
|
94
|
+
case `approved`: return json(await createSession(result.subject));
|
|
95
|
+
case `slow_down`: return json({
|
|
96
|
+
error: result.error,
|
|
97
|
+
interval: result.interval
|
|
98
|
+
}, 400);
|
|
99
|
+
case `pending`:
|
|
100
|
+
case `denied`:
|
|
101
|
+
case `expired`: return json({ error: result.error }, 400);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The approval endpoint. `GET` to resolve a code, `POST` to decide.
|
|
106
|
+
*
|
|
107
|
+
* Both require an authenticated user: this runs on the phone that is already
|
|
108
|
+
* signed in, and the identity it resolves is what the device inherits.
|
|
109
|
+
*/
|
|
110
|
+
const createApprovalHandler = ({ server, authenticate, rateLimit }) => async (request) => {
|
|
111
|
+
const subject = await authenticate(request);
|
|
112
|
+
if (subject === null) return json({ error: `unauthorized` }, 401);
|
|
113
|
+
if (request.method === `GET`) {
|
|
114
|
+
const userCode = new URL(request.url).searchParams.get(`user_code`);
|
|
115
|
+
if (userCode === null) return json({ error: `invalid_request` }, 400);
|
|
116
|
+
if (rateLimit && !await rateLimit(request, userCode)) return json({ error: `slow_down` }, 429);
|
|
117
|
+
const grant = await server.lookupByUserCode(userCode);
|
|
118
|
+
if (!grant || grant.status !== `pending`) return json({ error: `invalid_code` }, 404);
|
|
119
|
+
return json({
|
|
120
|
+
user_code: grant.user_code,
|
|
121
|
+
client_id: grant.clientId,
|
|
122
|
+
scope: grant.scope
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (request.method === `POST`) {
|
|
126
|
+
const params = await readParams(request);
|
|
127
|
+
const userCode = params.user_code;
|
|
128
|
+
if (userCode === void 0) return json({ error: `invalid_request` }, 400);
|
|
129
|
+
if (rateLimit && !await rateLimit(request, userCode)) return json({ error: `slow_down` }, 429);
|
|
130
|
+
const approved = params.approved === `true`;
|
|
131
|
+
const result = approved ? await server.approve(userCode, subject) : await server.deny(userCode);
|
|
132
|
+
return result.ok ? json({
|
|
133
|
+
ok: true,
|
|
134
|
+
approved
|
|
135
|
+
}) : json({ error: result.reason ?? `invalid_code` }, 400);
|
|
136
|
+
}
|
|
137
|
+
return json({ error: `method_not_allowed` }, 405);
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* All three handlers, plus a router for hosts that prefer one entry point.
|
|
141
|
+
*
|
|
142
|
+
* The router is a convenience — a Workers `fetch` can delegate to it wholesale
|
|
143
|
+
* — but the individual handlers exist so file-based routing (Astro, Next,
|
|
144
|
+
* SvelteKit) can mount each at its own path.
|
|
145
|
+
*/
|
|
146
|
+
const createHandlers = (options) => {
|
|
147
|
+
const authorize = createAuthorizationHandler(options);
|
|
148
|
+
const token = createTokenHandler(options);
|
|
149
|
+
const approval = createApprovalHandler(options);
|
|
150
|
+
return {
|
|
151
|
+
authorize,
|
|
152
|
+
token,
|
|
153
|
+
approval,
|
|
154
|
+
fetch: async (request) => {
|
|
155
|
+
const { pathname } = new URL(request.url);
|
|
156
|
+
if (pathname.endsWith(`/device/authorize`)) return authorize(request);
|
|
157
|
+
if (pathname.endsWith(`/device/token`)) return token(request);
|
|
158
|
+
if (pathname.endsWith(`/link`)) return approval(request);
|
|
159
|
+
return json({ error: `not_found` }, 404);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Serve the association files that make universal/app links work.
|
|
165
|
+
*
|
|
166
|
+
* Both must be served from the SAME origin as the approval page, over HTTPS,
|
|
167
|
+
* with no redirects. Apple and Google fetch them directly; a redirect or a
|
|
168
|
+
* wrong content-type makes the association fail silently, which is the usual
|
|
169
|
+
* reason "universal links don't work" with nothing in any log to explain it.
|
|
170
|
+
*
|
|
171
|
+
* Mount at `/.well-known/*`. Cached rather than `no-store` — unlike the auth
|
|
172
|
+
* endpoints these are static, and the platforms re-fetch them on their own
|
|
173
|
+
* schedule anyway.
|
|
174
|
+
*/
|
|
175
|
+
const createWellKnownHandler = ({ appleAppIds = [], androidPackageName, androidFingerprints = [], paths }) => {
|
|
176
|
+
const aasa = JSON.stringify(appleAppSiteAssociation(appleAppIds, { paths }));
|
|
177
|
+
const assetlinks = androidPackageName === void 0 ? `[]` : JSON.stringify(digitalAssetLinks(androidPackageName, androidFingerprints));
|
|
178
|
+
return (request) => {
|
|
179
|
+
const { pathname } = new URL(request.url);
|
|
180
|
+
if (pathname.endsWith(`/.well-known/apple-app-site-association`)) return new Response(aasa, { headers: {
|
|
181
|
+
"content-type": `application/json`,
|
|
182
|
+
"cache-control": `public, max-age=3600`
|
|
183
|
+
} });
|
|
184
|
+
if (pathname.endsWith(`/.well-known/assetlinks.json`)) return new Response(assetlinks, { headers: {
|
|
185
|
+
"content-type": `application/json`,
|
|
186
|
+
"cache-control": `public, max-age=3600`
|
|
187
|
+
} });
|
|
188
|
+
return null;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
//#endregion
|
|
192
|
+
export { createApprovalHandler, createAuthorizationHandler, createHandlers, createTokenHandler, createWellKnownHandler };
|
|
193
|
+
|
|
194
|
+
//# sourceMappingURL=handlers.mjs.map
|