@saeris/hanko 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE.md +21 -0
- package/README.md +605 -0
- package/dist/approve/index.d.mts +318 -0
- package/dist/approve/index.d.mts.map +1 -0
- package/dist/approve/index.mjs +393 -0
- package/dist/approve/index.mjs.map +1 -0
- package/dist/client/index.d.mts +101 -0
- package/dist/client/index.d.mts.map +1 -0
- package/dist/client/index.mjs +215 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/codes-Ba_qYH6u.mjs +93 -0
- package/dist/codes-Ba_qYH6u.mjs.map +1 -0
- package/dist/handlers.d.mts +113 -0
- package/dist/handlers.d.mts.map +1 -0
- package/dist/handlers.mjs +194 -0
- package/dist/handlers.mjs.map +1 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +345 -0
- package/dist/index.mjs.map +1 -0
- package/dist/linking-DcQSMgem.mjs +177 -0
- package/dist/linking-DcQSMgem.mjs.map +1 -0
- package/dist/linking-nKoayyHf.d.mts +133 -0
- package/dist/linking-nKoayyHf.d.mts.map +1 -0
- package/dist/machine-CRHKjtoP.d.mts +223 -0
- package/dist/machine-CRHKjtoP.d.mts.map +1 -0
- package/dist/machine-D_5DAFxi.mjs +155 -0
- package/dist/machine-D_5DAFxi.mjs.map +1 -0
- package/dist/qr.d.mts +58 -0
- package/dist/qr.d.mts.map +1 -0
- package/dist/qr.mjs +27 -0
- package/dist/qr.mjs.map +1 -0
- package/dist/scan/index.d.mts +381 -0
- package/dist/scan/index.d.mts.map +1 -0
- package/dist/scan/index.mjs +409 -0
- package/dist/scan/index.mjs.map +1 -0
- package/dist/scan/worker.d.mts +2 -0
- package/dist/scan/worker.mjs +2 -0
- package/dist/server-BhoYRkCm.d.mts +257 -0
- package/dist/server-BhoYRkCm.d.mts.map +1 -0
- package/dist/stores/kv.d.mts +64 -0
- package/dist/stores/kv.d.mts.map +1 -0
- package/dist/stores/kv.mjs +87 -0
- package/dist/stores/kv.mjs.map +1 -0
- package/dist/stores/memory.d.mts +22 -0
- package/dist/stores/memory.d.mts.map +1 -0
- package/dist/stores/memory.mjs +42 -0
- package/dist/stores/memory.mjs.map +1 -0
- package/dist/types-BvBIFPH6.mjs +7 -0
- package/dist/types-BvBIFPH6.mjs.map +1 -0
- package/dist/types-C82lb-zX.d.mts +82 -0
- package/dist/types-C82lb-zX.d.mts.map +1 -0
- package/dist/worker-BdwaK1uX.mjs +5291 -0
- package/dist/worker-BdwaK1uX.mjs.map +1 -0
- package/dist/worker-DxbdBA2z.d.mts +164 -0
- package/dist/worker-DxbdBA2z.d.mts.map +1 -0
- package/package.json +116 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handlers.mjs","names":[],"sources":["../src/handlers.ts"],"sourcesContent":["/**\n * Request → Response glue for the three endpoints the flow needs.\n *\n * Built on the WinterTC `Request`/`Response` pair, so the same handlers run on\n * Cloudflare Workers, Vercel Functions, Deno Deploy, Bun, and Node 18+ without\n * a framework adapter. This is the layer hanko ships so a host app writes\n * routing, not protocol.\n *\n * Stateless by construction: nothing is held between invocations. Every\n * request loads its grant from the store, applies one transition, and writes\n * it back — which is what makes this safe on an edge runtime where the next\n * request may land on a different instance, or on an instance that was frozen\n * mid-flow.\n */\n\nimport { appleAppSiteAssociation, digitalAssetLinks } from \"./linking.js\";\nimport type { HankoServer } from \"./server.js\";\n\n/** JSON with a status. Kept local so nothing imports a framework helper. */\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: {\n \"content-type\": `application/json`,\n // These are one-shot auth responses. A cached `authorization_pending`\n // served to a later poll would stall the device until expiry.\n \"cache-control\": `no-store`\n }\n });\n\n/**\n * Read parameters from a form body or JSON.\n *\n * RFC 8628 specifies form encoding, but hosts routinely post JSON from their\n * own front end, and rejecting that would be pedantry rather than security.\n */\nconst readParams = async (\n request: Request\n): Promise<Partial<Record<string, string>>> => {\n const contentType = request.headers.get(`content-type`) ?? ``;\n\n if (contentType.includes(`application/json`)) {\n const body: unknown = await request.json();\n if (typeof body !== `object` || body === null) return {};\n return Object.fromEntries(\n Object.entries(body).map(([key, value]) => [key, String(value)])\n );\n }\n\n // `URLSearchParams` over `request.formData()`: the spec sends\n // `application/x-www-form-urlencoded`, which this parses exactly, while\n // `formData()` drags in multipart handling that is both deprecated on the\n // server and useless here — no field in this protocol is a file.\n const params: Partial<Record<string, string>> = {};\n for (const [key, value] of new URLSearchParams(await request.text())) {\n params[key] = value;\n }\n return params;\n};\n\n/**\n * Hook for the rate limiting RFC 8628 §5.1 requires.\n *\n * hanko deliberately does not implement it: an effective limiter needs the IP,\n * which lives in a platform-specific header (`CF-Connecting-IP`,\n * `x-forwarded-for`), and needs storage this library should not assume. What\n * it can do is make the seam explicit so the requirement is not silently\n * skipped.\n *\n * Return `false` to reject with 429.\n */\nexport type RateLimiter = (\n request: Request,\n userCode: string\n) => Promise<boolean> | boolean;\n\nexport interface HandlerOptions {\n server: HankoServer;\n /**\n * Identify the approving user from their session.\n *\n * The trust boundary of the whole flow: whatever this returns becomes the\n * `subject` the device is signed in as. Read it from YOUR session — never\n * from the request body, which the client controls.\n *\n * Return `null` when unauthenticated, and the approval endpoint answers 401.\n */\n authenticate: (request: Request) => Promise<string | null> | string | null;\n /**\n * Guard the approval endpoint. Strongly recommended — see {@link RateLimiter}.\n */\n rateLimit?: RateLimiter;\n}\n\n/**\n * Public origin this request actually arrived on.\n *\n * `request.url` is unreliable behind a proxy: it carries the internal host the\n * proxy forwarded to, not the one the client typed. `x-forwarded-host` and\n * `x-forwarded-proto` carry the real ones, and every common proxy sets them —\n * ngrok, Cloudflare, Vercel, nginx.\n *\n * Returns `null` when nothing usable is present, so the caller falls back to\n * its configured value rather than guessing.\n *\n * These headers are CLIENT-CONTROLLABLE when no proxy strips them, so this is\n * only safe for building a URL the same client will visit. Never use it for an\n * authorization decision.\n */\nconst forwardedOrigin = (request: Request): string | null => {\n const host = request.headers.get(`x-forwarded-host`);\n if (host === null || host.length === 0) return null;\n\n // A comma-separated list when several proxies chained; the first is the\n // original client-facing host. Each hop appends, so taking the last would\n // give the innermost proxy — the one host guaranteed to be private.\n const [first] = host.split(`,`);\n const cleaned = first.trim();\n if (cleaned.length === 0) return null;\n\n const proto = request.headers.get(`x-forwarded-proto`)?.split(`,`)[0]?.trim();\n return `${proto === undefined || proto.length === 0 ? `https` : proto}://${cleaned}`;\n};\n\n/**\n * The device-authorization endpoint. `POST /device/authorize`.\n *\n * Unauthenticated: the whole point is that the device has no credentials yet.\n */\nexport const createAuthorizationHandler =\n ({\n server,\n verificationPath = `/link`,\n trustForwardedHost = true\n }: Pick<HandlerOptions, `server`> & {\n /** Path of the approval page, appended to the detected origin. */\n verificationPath?: string;\n /**\n * Derive the verification URI from the request's forwarded headers.\n *\n * On by default because it is what makes one deployment work across a\n * preview URL, a custom domain, and a tunnel without a redeploy. Set false\n * to always use the configured `verificationUri` — worth doing if your\n * platform does not strip client-sent `x-forwarded-*` headers and you would\n * rather pin the origin than trust them.\n */\n trustForwardedHost?: boolean;\n }) =>\n async (request: Request): Promise<Response> => {\n if (request.method !== `POST`) {\n return json({ error: `method_not_allowed` }, 405);\n }\n\n const origin = trustForwardedHost ? forwardedOrigin(request) : null;\n const params = await readParams(request);\n const grant = await server.requestAuthorization({\n clientId: params.client_id,\n scope: params.scope,\n // Undefined falls through to the server's configured value.\n verificationUri:\n origin === null ? undefined : `${origin}${verificationPath}`\n });\n return json(grant);\n };\n\n/**\n * The token endpoint. `POST /device/token`.\n *\n * Maps poll results onto the status codes RFC 8628 §3.5 specifies: pending and\n * slow_down are 400s carrying an error code, not 200s, because a compliant\n * client distinguishes them by body rather than status.\n */\nexport const createTokenHandler =\n ({\n server,\n createSession\n }: Pick<HandlerOptions, `server`> & {\n /**\n * Mint whatever credential the device should receive.\n *\n * hanko carries the `subject` and stops there — issuing sessions is your\n * auth system's job, and duplicating it would make this library compete\n * with Better-Auth instead of composing with it.\n */\n createSession: (subject: string) => unknown;\n }) =>\n async (request: Request): Promise<Response> => {\n if (request.method !== `POST`) {\n return json({ error: `method_not_allowed` }, 405);\n }\n\n const params = await readParams(request);\n const deviceCode = params.device_code;\n if (deviceCode === undefined) {\n return json({ error: `invalid_request` }, 400);\n }\n\n const result = await server.poll(deviceCode);\n\n switch (result.status) {\n case `approved`:\n return json(await createSession(result.subject));\n case `slow_down`:\n return json({ error: result.error, interval: result.interval }, 400);\n case `pending`:\n case `denied`:\n case `expired`:\n return json({ error: result.error }, 400);\n }\n };\n\n/**\n * The approval endpoint. `GET` to resolve a code, `POST` to decide.\n *\n * Both require an authenticated user: this runs on the phone that is already\n * signed in, and the identity it resolves is what the device inherits.\n */\nexport const createApprovalHandler =\n ({ server, authenticate, rateLimit }: HandlerOptions) =>\n async (request: Request): Promise<Response> => {\n const subject = await authenticate(request);\n if (subject === null) return json({ error: `unauthorized` }, 401);\n\n if (request.method === `GET`) {\n const userCode = new URL(request.url).searchParams.get(`user_code`);\n if (userCode === null) return json({ error: `invalid_request` }, 400);\n\n if (rateLimit && !(await rateLimit(request, userCode))) {\n return json({ error: `slow_down` }, 429);\n }\n\n const grant = await server.lookupByUserCode(userCode);\n // Unknown and expired collapse to one answer: distinguishing them would\n // let an attacker probe which codes are live.\n if (!grant || grant.status !== `pending`) {\n return json({ error: `invalid_code` }, 404);\n }\n\n // Returns the code, because RFC 8628 §3.3.1 asks for exactly that:\n //\n // \"The server SHOULD display the user_code to the user and ask them to\n // verify that it matches the user_code being displayed on the device\n // to confirm they are authorizing the correct device.\"\n //\n // The mitigation is a VISUAL comparison against a physically separate\n // screen, not a memory test. Withholding it would leave the user\n // approving an unlabelled request, which is strictly worse: they would\n // have nothing to compare at all.\n //\n // `device_code` is never returned. That one IS a bearer credential —\n // echoing it would let anyone who can resolve a user code redeem the\n // grant themselves.\n return json({\n user_code: grant.user_code,\n client_id: grant.clientId,\n scope: grant.scope\n });\n }\n\n if (request.method === `POST`) {\n const params = await readParams(request);\n const userCode = params.user_code;\n if (userCode === undefined)\n return json({ error: `invalid_request` }, 400);\n\n if (rateLimit && !(await rateLimit(request, userCode))) {\n return json({ error: `slow_down` }, 429);\n }\n\n // Explicit opt-in to approval. A missing field denies rather than\n // approves: a malformed request must never grant access.\n const approved = params.approved === `true`;\n const result = approved\n ? await server.approve(userCode, subject)\n : await server.deny(userCode);\n\n return result.ok\n ? json({ ok: true, approved })\n : json({ error: result.reason ?? `invalid_code` }, 400);\n }\n\n return json({ error: `method_not_allowed` }, 405);\n };\n\n/**\n * All three handlers, plus a router for hosts that prefer one entry point.\n *\n * The router is a convenience — a Workers `fetch` can delegate to it wholesale\n * — but the individual handlers exist so file-based routing (Astro, Next,\n * SvelteKit) can mount each at its own path.\n */\nexport const createHandlers = (\n options: HandlerOptions & {\n createSession: (subject: string) => unknown;\n }\n): {\n authorize: (request: Request) => Promise<Response>;\n token: (request: Request) => Promise<Response>;\n approval: (request: Request) => Promise<Response>;\n fetch: (request: Request) => Promise<Response>;\n} => {\n const authorize = createAuthorizationHandler(options);\n const token = createTokenHandler(options);\n const approval = createApprovalHandler(options);\n\n return {\n authorize,\n token,\n approval,\n fetch: async (request) => {\n const { pathname } = new URL(request.url);\n if (pathname.endsWith(`/device/authorize`)) return authorize(request);\n if (pathname.endsWith(`/device/token`)) return token(request);\n if (pathname.endsWith(`/link`)) return approval(request);\n return json({ error: `not_found` }, 404);\n }\n };\n};\n\n/**\n * Serve the association files that make universal/app links work.\n *\n * Both must be served from the SAME origin as the approval page, over HTTPS,\n * with no redirects. Apple and Google fetch them directly; a redirect or a\n * wrong content-type makes the association fail silently, which is the usual\n * reason \"universal links don't work\" with nothing in any log to explain it.\n *\n * Mount at `/.well-known/*`. Cached rather than `no-store` — unlike the auth\n * endpoints these are static, and the platforms re-fetch them on their own\n * schedule anyway.\n */\nexport const createWellKnownHandler = ({\n appleAppIds = [],\n androidPackageName,\n androidFingerprints = [],\n paths\n}: {\n /** `<TEAM_ID>.<BUNDLE_ID>` for each iOS app that may open these links. */\n appleAppIds?: string[];\n androidPackageName?: string;\n /** SHA-256 of the PLAY-signed certificate, not the local keystore. */\n androidFingerprints?: string[];\n /** Paths the apps claim. Defaults to the approval route. */\n paths?: string[];\n}) => {\n const aasa = JSON.stringify(appleAppSiteAssociation(appleAppIds, { paths }));\n const assetlinks =\n androidPackageName === undefined\n ? `[]`\n : JSON.stringify(\n digitalAssetLinks(androidPackageName, androidFingerprints)\n );\n\n return (request: Request): Response | null => {\n const { pathname } = new URL(request.url);\n\n // `application/json` with no extension — Apple rejects other content types,\n // and the file deliberately has no `.json` suffix.\n if (pathname.endsWith(`/.well-known/apple-app-site-association`)) {\n return new Response(aasa, {\n headers: {\n \"content-type\": `application/json`,\n \"cache-control\": `public, max-age=3600`\n }\n });\n }\n\n if (pathname.endsWith(`/.well-known/assetlinks.json`)) {\n return new Response(assetlinks, {\n headers: {\n \"content-type\": `application/json`,\n \"cache-control\": `public, max-age=3600`\n }\n });\n }\n\n // Null rather than 404, so a host can fall through to its own routes.\n return null;\n };\n};\n"],"mappings":"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"}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { _ as isGrantSettled, a as MAX_BACKOFF_SECONDS, b as pollTransition, c as PollState, d as canTransitionApproval, f as canTransitionGrant, g as isApprovalSettled, h as grantTransition, i as GrantState, l as SLOW_DOWN_INCREMENT_SECONDS, m as eventForTokenError, n as ApprovalState, o as PollContext, p as canTransitionPoll, r as GrantEvent, s as PollEvent, t as ApprovalEvent, u as approvalTransition, v as isPollSettled, y as pollContextTransition } from "./machine-CRHKjtoP.mjs";
|
|
2
|
+
import { a as buildAppSchemeUrl, c as digitalAssetLinks, d as pwaLaunchHandler, i as appleAppSiteAssociation, l as expoLinkingConfig, n as LinkSource, o as buildApprovalUrl, r as ParsedApprovalLink, t as LinkConfig, u as parseApprovalLink } from "./linking-nKoayyHf.mjs";
|
|
3
|
+
import { a as DeviceGrantStore, i as DeviceGrant, n as DeviceAuthorizationError, r as DeviceAuthorizationResponse, t as DEVICE_CODE_GRANT_TYPE } from "./types-C82lb-zX.mjs";
|
|
4
|
+
import { a as createHankoServer, c as BASE20_ALPHABET, d as generateDeviceCode, f as generateUserCode, i as PollResult, l as NUMERIC_ALPHABET, n as HankoServer, o as Grant, p as normalizeUserCode, r as HankoServerOptions, s as GrantHooks, t as ApproveResult, u as UserCodeOptions } from "./server-BhoYRkCm.mjs";
|
|
5
|
+
export { type ApprovalEvent, type ApprovalState, type ApproveResult, BASE20_ALPHABET, DEVICE_CODE_GRANT_TYPE, type DeviceAuthorizationError, type DeviceAuthorizationResponse, type DeviceGrant, type DeviceGrantStore, Grant, type GrantEvent, type GrantHooks, type GrantState, HankoServer, type HankoServerOptions, type LinkConfig, type LinkSource, MAX_BACKOFF_SECONDS, NUMERIC_ALPHABET, type ParsedApprovalLink, type PollContext, type PollEvent, type PollResult, type PollState, SLOW_DOWN_INCREMENT_SECONDS, type UserCodeOptions, appleAppSiteAssociation, approvalTransition, buildAppSchemeUrl, buildApprovalUrl, canTransitionApproval, canTransitionGrant, canTransitionPoll, createHankoServer, digitalAssetLinks, eventForTokenError, expoLinkingConfig, generateDeviceCode, generateUserCode, grantTransition, isApprovalSettled, isGrantSettled, isPollSettled, normalizeUserCode, parseApprovalLink, pollContextTransition, pollTransition, pwaLaunchHandler };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { a as normalizeUserCode, i as generateUserCode, n as NUMERIC_ALPHABET, r as generateDeviceCode, t as BASE20_ALPHABET } from "./codes-Ba_qYH6u.mjs";
|
|
2
|
+
import { a as canTransitionGrant, c as grantTransition, d as isPollSettled, f as pollContextTransition, i as canTransitionApproval, l as isApprovalSettled, n as SLOW_DOWN_INCREMENT_SECONDS, o as canTransitionPoll, p as pollTransition, r as approvalTransition, s as eventForTokenError, t as MAX_BACKOFF_SECONDS, u as isGrantSettled } from "./machine-D_5DAFxi.mjs";
|
|
3
|
+
import { t as DEVICE_CODE_GRANT_TYPE } from "./types-BvBIFPH6.mjs";
|
|
4
|
+
import { a as digitalAssetLinks, c as pwaLaunchHandler, n as buildAppSchemeUrl, o as expoLinkingConfig, r as buildApprovalUrl, s as parseApprovalLink, t as appleAppSiteAssociation } from "./linking-DcQSMgem.mjs";
|
|
5
|
+
//#region src/grant.ts
|
|
6
|
+
/**
|
|
7
|
+
* A single authorization attempt, as an object that owns its own state.
|
|
8
|
+
*
|
|
9
|
+
* The state machine in `machine.ts` is pure; this class is the boundary around
|
|
10
|
+
* it. `#private` fields are a genuine runtime boundary, not a compile-time
|
|
11
|
+
* convention — which matters here because what is being protected is a bearer
|
|
12
|
+
* credential (`device_code`) and, after approval, the identity that redeeming
|
|
13
|
+
* it hands over. Nothing outside this class can reach either by accident.
|
|
14
|
+
*
|
|
15
|
+
* Hooks let a host app observe transitions (persist, log, push to a UI) without
|
|
16
|
+
* being able to force one.
|
|
17
|
+
*/
|
|
18
|
+
var Grant = class Grant {
|
|
19
|
+
#deviceCode;
|
|
20
|
+
#userCode;
|
|
21
|
+
#expiresAt;
|
|
22
|
+
#clientId;
|
|
23
|
+
#scope;
|
|
24
|
+
#hooks;
|
|
25
|
+
#state;
|
|
26
|
+
#subject;
|
|
27
|
+
#interval;
|
|
28
|
+
#lastPolledAt;
|
|
29
|
+
constructor(grant, hooks = {}) {
|
|
30
|
+
this.#deviceCode = grant.device_code;
|
|
31
|
+
this.#userCode = grant.user_code;
|
|
32
|
+
this.#expiresAt = grant.expiresAt;
|
|
33
|
+
this.#clientId = grant.clientId;
|
|
34
|
+
this.#scope = grant.scope;
|
|
35
|
+
this.#state = grant.status;
|
|
36
|
+
this.#subject = grant.subject;
|
|
37
|
+
this.#interval = grant.interval;
|
|
38
|
+
this.#lastPolledAt = grant.lastPolledAt;
|
|
39
|
+
this.#hooks = hooks;
|
|
40
|
+
}
|
|
41
|
+
/** Rehydrate from a store record. */
|
|
42
|
+
static from(grant, hooks) {
|
|
43
|
+
return new Grant(grant, hooks);
|
|
44
|
+
}
|
|
45
|
+
get state() {
|
|
46
|
+
return this.#state;
|
|
47
|
+
}
|
|
48
|
+
get userCode() {
|
|
49
|
+
return this.#userCode;
|
|
50
|
+
}
|
|
51
|
+
get interval() {
|
|
52
|
+
return this.#interval;
|
|
53
|
+
}
|
|
54
|
+
get settled() {
|
|
55
|
+
return isGrantSettled(this.#state);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The approving identity — readable only once approved.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately not a plain field: reading it in any other state is a caller
|
|
61
|
+
* bug, and returning `undefined` silently would let it be handed to a session
|
|
62
|
+
* factory as an empty subject.
|
|
63
|
+
*/
|
|
64
|
+
get subject() {
|
|
65
|
+
if (this.#state !== `approved` && this.#state !== `consumed`) throw new Error(`subject is not available while the grant is ${this.#state}`);
|
|
66
|
+
return this.#subject ?? ``;
|
|
67
|
+
}
|
|
68
|
+
/** Whether this grant's deadline has passed as of `now`. */
|
|
69
|
+
expired(now) {
|
|
70
|
+
return now >= this.#expiresAt;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether a poll at `now` arrives sooner than the agreed interval.
|
|
74
|
+
*
|
|
75
|
+
* The first poll is always allowed; only a second one inside the window is
|
|
76
|
+
* early. Used by the server to decide between `authorization_pending` and
|
|
77
|
+
* `slow_down`.
|
|
78
|
+
*/
|
|
79
|
+
pollingTooSoon(now) {
|
|
80
|
+
return this.#lastPolledAt !== void 0 && now - this.#lastPolledAt < this.#interval * 1e3;
|
|
81
|
+
}
|
|
82
|
+
/** Record that a poll happened, without changing state. */
|
|
83
|
+
markPolled(now) {
|
|
84
|
+
this.#lastPolledAt = now;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Apply `slow_down`: add 5s permanently, per RFC 8628 §3.5.
|
|
88
|
+
*
|
|
89
|
+
* A method rather than a setter — the increment is the spec's, not the
|
|
90
|
+
* caller's, and exposing the interval for assignment would invite an
|
|
91
|
+
* exponential backoff that the spec reserves for connection failures.
|
|
92
|
+
*/
|
|
93
|
+
slowDown(now) {
|
|
94
|
+
this.#interval += 5;
|
|
95
|
+
this.#lastPolledAt = now;
|
|
96
|
+
return this.#interval;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Send an event. Returns whether it moved the grant.
|
|
100
|
+
*
|
|
101
|
+
* The only way to change state. Illegal events are rejected rather than
|
|
102
|
+
* throwing: double-approval and re-redemption are things a real caller does,
|
|
103
|
+
* and they must be no-ops rather than crashes.
|
|
104
|
+
*/
|
|
105
|
+
send(event) {
|
|
106
|
+
const from = this.#state;
|
|
107
|
+
const to = grantTransition(from, event);
|
|
108
|
+
if (to === from) {
|
|
109
|
+
this.#hooks.onRejected?.(from, event.type);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (event.type === `APPROVE`) this.#subject = event.subject;
|
|
113
|
+
this.#state = to;
|
|
114
|
+
const snapshot = this.toJSON();
|
|
115
|
+
this.#hooks.onTransition?.(from, to, snapshot);
|
|
116
|
+
switch (event.type) {
|
|
117
|
+
case `APPROVE`:
|
|
118
|
+
this.#hooks.onApproved?.(event.subject, snapshot);
|
|
119
|
+
break;
|
|
120
|
+
case `DENY`:
|
|
121
|
+
this.#hooks.onDenied?.(snapshot);
|
|
122
|
+
break;
|
|
123
|
+
case `EXPIRE`:
|
|
124
|
+
this.#hooks.onExpired?.(snapshot);
|
|
125
|
+
break;
|
|
126
|
+
case `REDEEM`: this.#hooks.onRedeemed?.(this.#subject ?? ``, snapshot);
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Plain record for persistence.
|
|
132
|
+
*
|
|
133
|
+
* Named `toJSON` so `JSON.stringify` picks it up — but note it includes
|
|
134
|
+
* `device_code` and `subject`, so it is a store payload, not something to
|
|
135
|
+
* send to a client.
|
|
136
|
+
*/
|
|
137
|
+
toJSON() {
|
|
138
|
+
return {
|
|
139
|
+
device_code: this.#deviceCode,
|
|
140
|
+
user_code: this.#userCode,
|
|
141
|
+
status: this.#state,
|
|
142
|
+
expiresAt: this.#expiresAt,
|
|
143
|
+
interval: this.#interval,
|
|
144
|
+
clientId: this.#clientId,
|
|
145
|
+
scope: this.#scope,
|
|
146
|
+
subject: this.#subject,
|
|
147
|
+
lastPolledAt: this.#lastPolledAt
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/server.ts
|
|
153
|
+
/**
|
|
154
|
+
* Server side of the device-authorization flow (RFC 8628).
|
|
155
|
+
*
|
|
156
|
+
* Framework-agnostic and transport-agnostic: this module never touches HTTP.
|
|
157
|
+
* Host apps wire these methods to whatever routes they like — Astro endpoints,
|
|
158
|
+
* Next route handlers, Workers, Hono. That is what makes the same core usable
|
|
159
|
+
* from Better-Auth, Supabase, or a bare in-memory dev server.
|
|
160
|
+
*
|
|
161
|
+
* hanko does not issue sessions or tokens. `approve()` records WHO approved (an
|
|
162
|
+
* opaque `subject`), and a successful poll hands that subject back to the host
|
|
163
|
+
* app, which mints whatever credential it already knows how to mint. Owning
|
|
164
|
+
* session issuance would duplicate Better-Auth rather than integrate with it.
|
|
165
|
+
*
|
|
166
|
+
* State transitions live in `machine.ts` and are applied through `Grant`, which
|
|
167
|
+
* owns them privately. Nothing here mutates a grant's status directly.
|
|
168
|
+
*/
|
|
169
|
+
var HankoServer = class {
|
|
170
|
+
#store;
|
|
171
|
+
#verificationUri;
|
|
172
|
+
#buildVerificationUriComplete;
|
|
173
|
+
#expiresInSeconds;
|
|
174
|
+
#intervalSeconds;
|
|
175
|
+
#userCodeOptions;
|
|
176
|
+
#now;
|
|
177
|
+
#hooks;
|
|
178
|
+
constructor({ store, verificationUri, buildVerificationUriComplete = (userCode, uri) => `${uri}${uri.includes(`?`) ? `&` : `?`}user_code=${encodeURIComponent(userCode)}`, expiresInSeconds = 900, intervalSeconds = 5, userCode, now = () => Date.now(), hooks = {} }) {
|
|
179
|
+
this.#store = store;
|
|
180
|
+
this.#verificationUri = verificationUri;
|
|
181
|
+
this.#buildVerificationUriComplete = buildVerificationUriComplete;
|
|
182
|
+
this.#expiresInSeconds = expiresInSeconds;
|
|
183
|
+
this.#intervalSeconds = intervalSeconds;
|
|
184
|
+
this.#userCodeOptions = userCode;
|
|
185
|
+
this.#now = now;
|
|
186
|
+
this.#hooks = hooks;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Load a grant and settle its deadline before anyone reads its state.
|
|
190
|
+
*
|
|
191
|
+
* Expiry is evaluated lazily on read rather than by a timer. A background
|
|
192
|
+
* sweep cannot be relied on: serverless instances die, and a TV left on for
|
|
193
|
+
* days outlives any interval we set. Checking at the point of use means a
|
|
194
|
+
* grant is never observed as live past its deadline, whatever the host's
|
|
195
|
+
* lifecycle looks like.
|
|
196
|
+
*/
|
|
197
|
+
async #load(record) {
|
|
198
|
+
if (!record) return null;
|
|
199
|
+
const grant = Grant.from(record, this.#hooks);
|
|
200
|
+
if (grant.expired(this.#now()) && grant.send({ type: `EXPIRE` })) await this.#store.update(grant.toJSON());
|
|
201
|
+
return grant;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Start a flow. Call from the device-authorization endpoint.
|
|
205
|
+
*
|
|
206
|
+
* Returns the spec's response shape directly, so a host route can serialize
|
|
207
|
+
* it as-is.
|
|
208
|
+
*/
|
|
209
|
+
async requestAuthorization({ clientId, scope, verificationUri = this.#verificationUri } = {}) {
|
|
210
|
+
const displayCode = generateUserCode(this.#userCodeOptions);
|
|
211
|
+
const record = {
|
|
212
|
+
device_code: generateDeviceCode(),
|
|
213
|
+
user_code: normalizeUserCode(displayCode),
|
|
214
|
+
status: `pending`,
|
|
215
|
+
expiresAt: this.#now() + this.#expiresInSeconds * 1e3,
|
|
216
|
+
interval: this.#intervalSeconds,
|
|
217
|
+
clientId,
|
|
218
|
+
scope
|
|
219
|
+
};
|
|
220
|
+
await this.#store.create(record);
|
|
221
|
+
await this.#store.prune?.(this.#now());
|
|
222
|
+
return {
|
|
223
|
+
device_code: record.device_code,
|
|
224
|
+
user_code: displayCode,
|
|
225
|
+
verification_uri: verificationUri,
|
|
226
|
+
verification_uri_complete: this.#buildVerificationUriComplete(displayCode, verificationUri),
|
|
227
|
+
expires_in: this.#expiresInSeconds,
|
|
228
|
+
interval: this.#intervalSeconds
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Poll for a decision. Call from the token endpoint.
|
|
233
|
+
*
|
|
234
|
+
* Enforces the interval: polling faster than allowed returns `slow_down` and
|
|
235
|
+
* permanently raises this grant's interval by 5s, per §3.5. The increase is
|
|
236
|
+
* additive and sticky — NOT exponential backoff, which the spec reserves for
|
|
237
|
+
* connection timeouts.
|
|
238
|
+
*/
|
|
239
|
+
async poll(deviceCode) {
|
|
240
|
+
const grant = await this.#load(await this.#store.findByDeviceCode(deviceCode));
|
|
241
|
+
if (!grant) return {
|
|
242
|
+
status: `expired`,
|
|
243
|
+
error: `expired_token`
|
|
244
|
+
};
|
|
245
|
+
switch (grant.state) {
|
|
246
|
+
case `denied`: return {
|
|
247
|
+
status: `denied`,
|
|
248
|
+
error: `access_denied`
|
|
249
|
+
};
|
|
250
|
+
case `consumed`:
|
|
251
|
+
case `expired`: return {
|
|
252
|
+
status: `expired`,
|
|
253
|
+
error: `expired_token`
|
|
254
|
+
};
|
|
255
|
+
case `approved`: {
|
|
256
|
+
const { subject } = grant;
|
|
257
|
+
grant.send({ type: `REDEEM` });
|
|
258
|
+
await this.#store.update(grant.toJSON());
|
|
259
|
+
return {
|
|
260
|
+
status: `approved`,
|
|
261
|
+
subject
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
case `pending`: {
|
|
265
|
+
const at = this.#now();
|
|
266
|
+
if (grant.pollingTooSoon(at)) {
|
|
267
|
+
const interval = grant.slowDown(at);
|
|
268
|
+
await this.#store.update(grant.toJSON());
|
|
269
|
+
return {
|
|
270
|
+
status: `slow_down`,
|
|
271
|
+
error: `slow_down`,
|
|
272
|
+
interval
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
grant.markPolled(at);
|
|
276
|
+
await this.#store.update(grant.toJSON());
|
|
277
|
+
return {
|
|
278
|
+
status: `pending`,
|
|
279
|
+
error: `authorization_pending`
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Look up a grant by the code the user typed or arrived with.
|
|
286
|
+
*
|
|
287
|
+
* The approval page MUST display the returned `user_code` back to the user so
|
|
288
|
+
* they can confirm it matches the screen — RFC 8628 §5.4. That check is the
|
|
289
|
+
* only defense against a phished QR pointing at an attacker's device, so it
|
|
290
|
+
* is not optional UI polish.
|
|
291
|
+
*/
|
|
292
|
+
async lookupByUserCode(input) {
|
|
293
|
+
return (await this.#load(await this.#store.findByUserCode(normalizeUserCode(input))))?.toJSON() ?? null;
|
|
294
|
+
}
|
|
295
|
+
/** Record approval. `subject` is opaque to hanko; it is echoed back on poll. */
|
|
296
|
+
async approve(input, subject) {
|
|
297
|
+
return this.#decide(input, {
|
|
298
|
+
type: `APPROVE`,
|
|
299
|
+
subject
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/** Record denial, so the device can stop polling and say why. */
|
|
303
|
+
async deny(input) {
|
|
304
|
+
return this.#decide(input, { type: `DENY` });
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Shared path for approve/deny.
|
|
308
|
+
*
|
|
309
|
+
* Both are the same operation modulo the event, and the machine — not a
|
|
310
|
+
* ladder of guard clauses here — decides whether the move is legal. That is
|
|
311
|
+
* what makes approving a denied grant, or re-deciding a consumed one,
|
|
312
|
+
* impossible rather than merely checked for.
|
|
313
|
+
*/
|
|
314
|
+
async #decide(input, event) {
|
|
315
|
+
const grant = await this.#load(await this.#store.findByUserCode(normalizeUserCode(input)));
|
|
316
|
+
if (!grant) return {
|
|
317
|
+
ok: false,
|
|
318
|
+
reason: `not_found`
|
|
319
|
+
};
|
|
320
|
+
if (!grant.send(event)) return {
|
|
321
|
+
ok: false,
|
|
322
|
+
reason: reasonFor(grant.state),
|
|
323
|
+
grant: grant.toJSON()
|
|
324
|
+
};
|
|
325
|
+
const snapshot = grant.toJSON();
|
|
326
|
+
await this.#store.update(snapshot);
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
grant: snapshot
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
/** Why a decision was refused, from the state that refused it. */
|
|
334
|
+
const reasonFor = (state) => state === `expired` ? `expired` : `already_resolved`;
|
|
335
|
+
/**
|
|
336
|
+
* Factory kept for ergonomics and backwards compatibility.
|
|
337
|
+
*
|
|
338
|
+
* The class is the real API; this is sugar for callers who prefer not to write
|
|
339
|
+
* `new`, and it keeps existing call sites working.
|
|
340
|
+
*/
|
|
341
|
+
const createHankoServer = (options) => new HankoServer(options);
|
|
342
|
+
//#endregion
|
|
343
|
+
export { BASE20_ALPHABET, DEVICE_CODE_GRANT_TYPE, Grant, HankoServer, MAX_BACKOFF_SECONDS, NUMERIC_ALPHABET, SLOW_DOWN_INCREMENT_SECONDS, appleAppSiteAssociation, approvalTransition, buildAppSchemeUrl, buildApprovalUrl, canTransitionApproval, canTransitionGrant, canTransitionPoll, createHankoServer, digitalAssetLinks, eventForTokenError, expoLinkingConfig, generateDeviceCode, generateUserCode, grantTransition, isApprovalSettled, isGrantSettled, isPollSettled, normalizeUserCode, parseApprovalLink, pollContextTransition, pollTransition, pwaLaunchHandler };
|
|
344
|
+
|
|
345
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["#deviceCode","#userCode","#expiresAt","#clientId","#scope","#hooks","#state","#subject","#interval","#lastPolledAt","#store","#verificationUri","#buildVerificationUriComplete","#expiresInSeconds","#intervalSeconds","#userCodeOptions","#now","#hooks","#load","#decide"],"sources":["../src/grant.ts","../src/server.ts"],"sourcesContent":["/**\n * A single authorization attempt, as an object that owns its own state.\n *\n * The state machine in `machine.ts` is pure; this class is the boundary around\n * it. `#private` fields are a genuine runtime boundary, not a compile-time\n * convention — which matters here because what is being protected is a bearer\n * credential (`device_code`) and, after approval, the identity that redeeming\n * it hands over. Nothing outside this class can reach either by accident.\n *\n * Hooks let a host app observe transitions (persist, log, push to a UI) without\n * being able to force one.\n */\n\nimport {\n grantTransition,\n isGrantSettled,\n type GrantEvent,\n type GrantState\n} from \"./machine.js\";\nimport type { DeviceGrant } from \"./types.js\";\n\n/** Observers of a grant's lifecycle. All optional, all fire after the move. */\nexport interface GrantHooks {\n /** Any successful transition. */\n onTransition?: (from: GrantState, to: GrantState, grant: DeviceGrant) => void;\n /** The user authorized. `subject` is whoever they are to the host app. */\n onApproved?: (subject: string, grant: DeviceGrant) => void;\n /** The user refused. */\n onDenied?: (grant: DeviceGrant) => void;\n /** The deadline passed without a decision, or before redemption. */\n onExpired?: (grant: DeviceGrant) => void;\n /** The device redeemed its approval. Terminal. */\n onRedeemed?: (subject: string, grant: DeviceGrant) => void;\n /**\n * An event the current state does not accept.\n *\n * Not an error — a device polling twice in a row legitimately produces one —\n * but worth surfacing, since a burst of them means a confused caller.\n */\n onRejected?: (state: GrantState, event: GrantEvent[`type`]) => void;\n}\n\nexport class Grant {\n readonly #deviceCode: string;\n readonly #userCode: string;\n readonly #expiresAt: number;\n readonly #clientId: string | undefined;\n readonly #scope: string | undefined;\n readonly #hooks: GrantHooks;\n\n #state: GrantState;\n #subject: string | undefined;\n #interval: number;\n #lastPolledAt: number | undefined;\n\n constructor(grant: DeviceGrant, hooks: GrantHooks = {}) {\n this.#deviceCode = grant.device_code;\n this.#userCode = grant.user_code;\n this.#expiresAt = grant.expiresAt;\n this.#clientId = grant.clientId;\n this.#scope = grant.scope;\n this.#state = grant.status;\n this.#subject = grant.subject;\n this.#interval = grant.interval;\n this.#lastPolledAt = grant.lastPolledAt;\n this.#hooks = hooks;\n }\n\n /** Rehydrate from a store record. */\n static from(grant: DeviceGrant, hooks?: GrantHooks): Grant {\n return new Grant(grant, hooks);\n }\n\n get state(): GrantState {\n return this.#state;\n }\n\n get userCode(): string {\n return this.#userCode;\n }\n\n get interval(): number {\n return this.#interval;\n }\n\n get settled(): boolean {\n return isGrantSettled(this.#state);\n }\n\n /**\n * The approving identity — readable only once approved.\n *\n * Deliberately not a plain field: reading it in any other state is a caller\n * bug, and returning `undefined` silently would let it be handed to a session\n * factory as an empty subject.\n */\n get subject(): string {\n if (this.#state !== `approved` && this.#state !== `consumed`) {\n throw new Error(\n `subject is not available while the grant is ${this.#state}`\n );\n }\n return this.#subject ?? ``;\n }\n\n /** Whether this grant's deadline has passed as of `now`. */\n expired(now: number): boolean {\n return now >= this.#expiresAt;\n }\n\n /**\n * Whether a poll at `now` arrives sooner than the agreed interval.\n *\n * The first poll is always allowed; only a second one inside the window is\n * early. Used by the server to decide between `authorization_pending` and\n * `slow_down`.\n */\n pollingTooSoon(now: number): boolean {\n return (\n this.#lastPolledAt !== undefined &&\n now - this.#lastPolledAt < this.#interval * 1000\n );\n }\n\n /** Record that a poll happened, without changing state. */\n markPolled(now: number): void {\n this.#lastPolledAt = now;\n }\n\n /**\n * Apply `slow_down`: add 5s permanently, per RFC 8628 §3.5.\n *\n * A method rather than a setter — the increment is the spec's, not the\n * caller's, and exposing the interval for assignment would invite an\n * exponential backoff that the spec reserves for connection failures.\n */\n slowDown(now: number): number {\n this.#interval += 5;\n this.#lastPolledAt = now;\n return this.#interval;\n }\n\n /**\n * Send an event. Returns whether it moved the grant.\n *\n * The only way to change state. Illegal events are rejected rather than\n * throwing: double-approval and re-redemption are things a real caller does,\n * and they must be no-ops rather than crashes.\n */\n send(event: GrantEvent): boolean {\n const from = this.#state;\n const to = grantTransition(from, event);\n\n if (to === from) {\n this.#hooks.onRejected?.(from, event.type);\n return false;\n }\n\n // Set before hooks fire, so an observer that reads the grant sees the new\n // state rather than the one it just left.\n if (event.type === `APPROVE`) this.#subject = event.subject;\n this.#state = to;\n\n const snapshot = this.toJSON();\n this.#hooks.onTransition?.(from, to, snapshot);\n\n switch (event.type) {\n case `APPROVE`:\n this.#hooks.onApproved?.(event.subject, snapshot);\n break;\n case `DENY`:\n this.#hooks.onDenied?.(snapshot);\n break;\n case `EXPIRE`:\n this.#hooks.onExpired?.(snapshot);\n break;\n case `REDEEM`:\n this.#hooks.onRedeemed?.(this.#subject ?? ``, snapshot);\n break;\n }\n\n return true;\n }\n\n /**\n * Plain record for persistence.\n *\n * Named `toJSON` so `JSON.stringify` picks it up — but note it includes\n * `device_code` and `subject`, so it is a store payload, not something to\n * send to a client.\n */\n toJSON(): DeviceGrant {\n return {\n device_code: this.#deviceCode,\n user_code: this.#userCode,\n status: this.#state,\n expiresAt: this.#expiresAt,\n interval: this.#interval,\n clientId: this.#clientId,\n scope: this.#scope,\n subject: this.#subject,\n lastPolledAt: this.#lastPolledAt\n };\n }\n}\n","/**\n * Server side of the device-authorization flow (RFC 8628).\n *\n * Framework-agnostic and transport-agnostic: this module never touches HTTP.\n * Host apps wire these methods to whatever routes they like — Astro endpoints,\n * Next route handlers, Workers, Hono. That is what makes the same core usable\n * from Better-Auth, Supabase, or a bare in-memory dev server.\n *\n * hanko does not issue sessions or tokens. `approve()` records WHO approved (an\n * opaque `subject`), and a successful poll hands that subject back to the host\n * app, which mints whatever credential it already knows how to mint. Owning\n * session issuance would duplicate Better-Auth rather than integrate with it.\n *\n * State transitions live in `machine.ts` and are applied through `Grant`, which\n * owns them privately. Nothing here mutates a grant's status directly.\n */\n\nimport {\n generateDeviceCode,\n generateUserCode,\n normalizeUserCode\n} from \"./codes.js\";\nimport type { UserCodeOptions } from \"./codes.js\";\nimport { Grant, type GrantHooks } from \"./grant.js\";\nimport type { GrantState } from \"./machine.js\";\nimport type {\n DeviceAuthorizationError,\n DeviceAuthorizationResponse,\n DeviceGrant,\n DeviceGrantStore\n} from \"./types.js\";\n\nexport interface HankoServerOptions {\n /** Where grants live. Use `MemoryDeviceGrantStore` for dev. */\n store: DeviceGrantStore;\n /**\n * Absolute URL the user visits to approve, e.g. `https://example.com/link`.\n * Shown on the device verbatim, so keep it short and typeable.\n */\n verificationUri: string;\n /**\n * Builds the QR target. Defaults to `${verificationUri}?user_code=${code}`.\n * Override if your approval page reads the code from a path segment.\n */\n buildVerificationUriComplete?: (\n userCode: string,\n verificationUri: string\n ) => string;\n /** Code lifetime in seconds. Default 900 (15 min). */\n expiresInSeconds?: number;\n /** Starting poll interval in seconds. Spec default 5. */\n intervalSeconds?: number;\n /** Shape of the user code. See {@link UserCodeOptions}. */\n userCode?: UserCodeOptions;\n /** Injectable clock. Tests pass a fake; production leaves it alone. */\n now?: () => number;\n /**\n * Lifecycle observers, applied to every grant this server handles.\n *\n * The integration seam for host frameworks: persist to a second store, emit\n * telemetry, push to a websocket. Hooks observe; they cannot force a\n * transition.\n */\n hooks?: GrantHooks;\n}\n\n/** Discriminated result of a poll. Callers switch on `status`. */\nexport type PollResult =\n | {\n status: `pending`;\n error: Extract<DeviceAuthorizationError, `authorization_pending`>;\n }\n | {\n status: `slow_down`;\n error: Extract<DeviceAuthorizationError, `slow_down`>;\n interval: number;\n }\n | {\n status: `denied`;\n error: Extract<DeviceAuthorizationError, `access_denied`>;\n }\n | {\n status: `expired`;\n error: Extract<DeviceAuthorizationError, `expired_token`>;\n }\n | { status: `approved`; subject: string };\n\nexport interface ApproveResult {\n ok: boolean;\n /** Present when `ok` is false. Lets callers show a precise message. */\n reason?: `not_found` | `expired` | `already_resolved`;\n grant?: DeviceGrant;\n}\n\nexport class HankoServer {\n readonly #store: DeviceGrantStore;\n readonly #verificationUri: string;\n readonly #buildVerificationUriComplete: (\n userCode: string,\n verificationUri: string\n ) => string;\n readonly #expiresInSeconds: number;\n readonly #intervalSeconds: number;\n readonly #userCodeOptions: UserCodeOptions | undefined;\n readonly #now: () => number;\n readonly #hooks: GrantHooks;\n\n constructor({\n store,\n verificationUri,\n buildVerificationUriComplete = (userCode, uri): string =>\n `${uri}${uri.includes(`?`) ? `&` : `?`}user_code=${encodeURIComponent(userCode)}`,\n expiresInSeconds = 900,\n intervalSeconds = 5,\n userCode,\n now = (): number => Date.now(),\n hooks = {}\n }: HankoServerOptions) {\n this.#store = store;\n this.#verificationUri = verificationUri;\n this.#buildVerificationUriComplete = buildVerificationUriComplete;\n this.#expiresInSeconds = expiresInSeconds;\n this.#intervalSeconds = intervalSeconds;\n this.#userCodeOptions = userCode;\n this.#now = now;\n this.#hooks = hooks;\n }\n\n /**\n * Load a grant and settle its deadline before anyone reads its state.\n *\n * Expiry is evaluated lazily on read rather than by a timer. A background\n * sweep cannot be relied on: serverless instances die, and a TV left on for\n * days outlives any interval we set. Checking at the point of use means a\n * grant is never observed as live past its deadline, whatever the host's\n * lifecycle looks like.\n */\n async #load(record: DeviceGrant | null): Promise<Grant | null> {\n if (!record) return null;\n\n const grant = Grant.from(record, this.#hooks);\n // Sent unconditionally: the machine decides whether EXPIRE is legal in the\n // current state, so this correctly expires a stale `approved` grant that\n // was never redeemed, not just a `pending` one.\n if (grant.expired(this.#now()) && grant.send({ type: `EXPIRE` })) {\n await this.#store.update(grant.toJSON());\n }\n return grant;\n }\n\n /**\n * Start a flow. Call from the device-authorization endpoint.\n *\n * Returns the spec's response shape directly, so a host route can serialize\n * it as-is.\n */\n async requestAuthorization({\n clientId,\n scope,\n verificationUri = this.#verificationUri\n }: {\n clientId?: string;\n scope?: string;\n /**\n * Override the configured verification URI for this grant.\n *\n * One deployment is commonly reachable through several hostnames — a\n * preview URL, a custom domain, a tunnel — and the QR has to encode the one\n * the device is actually talking to. Derive it from the incoming request\n * (`x-forwarded-host` behind a proxy) and the code always points somewhere\n * reachable, with no redeploy when the hostname changes.\n */\n verificationUri?: string;\n } = {}): Promise<DeviceAuthorizationResponse> {\n const displayCode = generateUserCode(this.#userCodeOptions);\n const record: DeviceGrant = {\n device_code: generateDeviceCode(),\n // Stored normalized so lookup never depends on the display format.\n user_code: normalizeUserCode(displayCode),\n status: `pending`,\n expiresAt: this.#now() + this.#expiresInSeconds * 1000,\n interval: this.#intervalSeconds,\n clientId,\n scope\n };\n await this.#store.create(record);\n await this.#store.prune?.(this.#now());\n\n return {\n device_code: record.device_code,\n // The DISPLAY form goes on the wire — the device shows it verbatim.\n user_code: displayCode,\n verification_uri: verificationUri,\n verification_uri_complete: this.#buildVerificationUriComplete(\n displayCode,\n verificationUri\n ),\n expires_in: this.#expiresInSeconds,\n interval: this.#intervalSeconds\n };\n }\n\n /**\n * Poll for a decision. Call from the token endpoint.\n *\n * Enforces the interval: polling faster than allowed returns `slow_down` and\n * permanently raises this grant's interval by 5s, per §3.5. The increase is\n * additive and sticky — NOT exponential backoff, which the spec reserves for\n * connection timeouts.\n */\n async poll(deviceCode: string): Promise<PollResult> {\n const grant = await this.#load(\n await this.#store.findByDeviceCode(deviceCode)\n );\n // An unknown device_code is reported as expired rather than \"not found\":\n // distinguishing them would confirm which codes exist to an attacker.\n if (!grant) return { status: `expired`, error: `expired_token` };\n\n switch (grant.state) {\n case `denied`:\n return { status: `denied`, error: `access_denied` };\n // A consumed grant reports as expired: a device_code is a bearer\n // credential, so a replayed poll must not hand out the subject twice.\n case `consumed`:\n case `expired`:\n return { status: `expired`, error: `expired_token` };\n case `approved`: {\n // Read the subject BEFORE redeeming — `consumed` still permits it, but\n // ordering it this way keeps the read adjacent to the state that earned\n // it rather than depending on `consumed` staying readable.\n const { subject } = grant;\n grant.send({ type: `REDEEM` });\n await this.#store.update(grant.toJSON());\n return { status: `approved`, subject };\n }\n case `pending`: {\n const at = this.#now();\n if (grant.pollingTooSoon(at)) {\n const interval = grant.slowDown(at);\n await this.#store.update(grant.toJSON());\n return { status: `slow_down`, error: `slow_down`, interval };\n }\n grant.markPolled(at);\n await this.#store.update(grant.toJSON());\n return { status: `pending`, error: `authorization_pending` };\n }\n }\n }\n\n /**\n * Look up a grant by the code the user typed or arrived with.\n *\n * The approval page MUST display the returned `user_code` back to the user so\n * they can confirm it matches the screen — RFC 8628 §5.4. That check is the\n * only defense against a phished QR pointing at an attacker's device, so it\n * is not optional UI polish.\n */\n async lookupByUserCode(input: string): Promise<DeviceGrant | null> {\n const grant = await this.#load(\n await this.#store.findByUserCode(normalizeUserCode(input))\n );\n return grant?.toJSON() ?? null;\n }\n\n /** Record approval. `subject` is opaque to hanko; it is echoed back on poll. */\n async approve(input: string, subject: string): Promise<ApproveResult> {\n return this.#decide(input, { type: `APPROVE`, subject });\n }\n\n /** Record denial, so the device can stop polling and say why. */\n async deny(input: string): Promise<ApproveResult> {\n return this.#decide(input, { type: `DENY` });\n }\n\n /**\n * Shared path for approve/deny.\n *\n * Both are the same operation modulo the event, and the machine — not a\n * ladder of guard clauses here — decides whether the move is legal. That is\n * what makes approving a denied grant, or re-deciding a consumed one,\n * impossible rather than merely checked for.\n */\n async #decide(\n input: string,\n event: { type: `APPROVE`; subject: string } | { type: `DENY` }\n ): Promise<ApproveResult> {\n const grant = await this.#load(\n await this.#store.findByUserCode(normalizeUserCode(input))\n );\n if (!grant) return { ok: false, reason: `not_found` };\n\n if (!grant.send(event)) {\n return {\n ok: false,\n reason: reasonFor(grant.state),\n grant: grant.toJSON()\n };\n }\n\n const snapshot = grant.toJSON();\n await this.#store.update(snapshot);\n return { ok: true, grant: snapshot };\n }\n}\n\n/** Why a decision was refused, from the state that refused it. */\nconst reasonFor = (state: GrantState): `expired` | `already_resolved` =>\n state === `expired` ? `expired` : `already_resolved`;\n\n/**\n * Factory kept for ergonomics and backwards compatibility.\n *\n * The class is the real API; this is sugar for callers who prefer not to write\n * `new`, and it keeps existing call sites working.\n */\nexport const createHankoServer = (options: HankoServerOptions): HankoServer =>\n new HankoServer(options);\n"],"mappings":";;;;;;;;;;;;;;;;;AA0CA,IAAa,QAAb,MAAa,MAAM;CACjB;CACA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CACA;CAEA,YAAY,OAAoB,QAAoB,CAAC,GAAG;EACtD,KAAKA,cAAc,MAAM;EACzB,KAAKC,YAAY,MAAM;EACvB,KAAKC,aAAa,MAAM;EACxB,KAAKC,YAAY,MAAM;EACvB,KAAKC,SAAS,MAAM;EACpB,KAAKE,SAAS,MAAM;EACpB,KAAKC,WAAW,MAAM;EACtB,KAAKC,YAAY,MAAM;EACvB,KAAKC,gBAAgB,MAAM;EAC3B,KAAKJ,SAAS;CAChB;;CAGA,OAAO,KAAK,OAAoB,OAA2B;EACzD,OAAO,IAAI,MAAM,OAAO,KAAK;CAC/B;CAEA,IAAI,QAAoB;EACtB,OAAO,KAAKC;CACd;CAEA,IAAI,WAAmB;EACrB,OAAO,KAAKL;CACd;CAEA,IAAI,WAAmB;EACrB,OAAO,KAAKO;CACd;CAEA,IAAI,UAAmB;EACrB,OAAO,eAAe,KAAKF,MAAM;CACnC;;;;;;;;CASA,IAAI,UAAkB;EACpB,IAAI,KAAKA,WAAW,cAAc,KAAKA,WAAW,YAChD,MAAM,IAAI,MACR,+CAA+C,KAAKA,QACtD;EAEF,OAAO,KAAKC,YAAY;CAC1B;;CAGA,QAAQ,KAAsB;EAC5B,OAAO,OAAO,KAAKL;CACrB;;;;;;;;CASA,eAAe,KAAsB;EACnC,OACE,KAAKO,kBAAkB,KAAA,KACvB,MAAM,KAAKA,gBAAgB,KAAKD,YAAY;CAEhD;;CAGA,WAAW,KAAmB;EAC5B,KAAKC,gBAAgB;CACvB;;;;;;;;CASA,SAAS,KAAqB;EAC5B,KAAKD,aAAa;EAClB,KAAKC,gBAAgB;EACrB,OAAO,KAAKD;CACd;;;;;;;;CASA,KAAK,OAA4B;EAC/B,MAAM,OAAO,KAAKF;EAClB,MAAM,KAAK,gBAAgB,MAAM,KAAK;EAEtC,IAAI,OAAO,MAAM;GACf,KAAKD,OAAO,aAAa,MAAM,MAAM,IAAI;GACzC,OAAO;EACT;EAIA,IAAI,MAAM,SAAS,WAAW,KAAKE,WAAW,MAAM;EACpD,KAAKD,SAAS;EAEd,MAAM,WAAW,KAAK,OAAO;EAC7B,KAAKD,OAAO,eAAe,MAAM,IAAI,QAAQ;EAE7C,QAAQ,MAAM,MAAd;GACE,KAAK;IACH,KAAKA,OAAO,aAAa,MAAM,SAAS,QAAQ;IAChD;GACF,KAAK;IACH,KAAKA,OAAO,WAAW,QAAQ;IAC/B;GACF,KAAK;IACH,KAAKA,OAAO,YAAY,QAAQ;IAChC;GACF,KAAK,UACH,KAAKA,OAAO,aAAa,KAAKE,YAAY,IAAI,QAAQ;EAE1D;EAEA,OAAO;CACT;;;;;;;;CASA,SAAsB;EACpB,OAAO;GACL,aAAa,KAAKP;GAClB,WAAW,KAAKC;GAChB,QAAQ,KAAKK;GACb,WAAW,KAAKJ;GAChB,UAAU,KAAKM;GACf,UAAU,KAAKL;GACf,OAAO,KAAKC;GACZ,SAAS,KAAKG;GACd,cAAc,KAAKE;EACrB;CACF;AACF;;;;;;;;;;;;;;;;;;;AC9GA,IAAa,cAAb,MAAyB;CACvB;CACA;CACA;CAIA;CACA;CACA;CACA;CACA;CAEA,YAAY,EACV,OACA,iBACA,gCAAgC,UAAU,QACxC,GAAG,MAAM,IAAI,SAAS,GAAG,IAAI,MAAM,IAAI,YAAY,mBAAmB,QAAQ,KAChF,mBAAmB,KACnB,kBAAkB,GAClB,UACA,YAAoB,KAAK,IAAI,GAC7B,QAAQ,CAAC,KACY;EACrB,KAAKC,SAAS;EACd,KAAKC,mBAAmB;EACxB,KAAKC,gCAAgC;EACrC,KAAKC,oBAAoB;EACzB,KAAKC,mBAAmB;EACxB,KAAKC,mBAAmB;EACxB,KAAKC,OAAO;EACZ,KAAKC,SAAS;CAChB;;;;;;;;;;CAWA,MAAMC,MAAM,QAAmD;EAC7D,IAAI,CAAC,QAAQ,OAAO;EAEpB,MAAM,QAAQ,MAAM,KAAK,QAAQ,KAAKD,MAAM;EAI5C,IAAI,MAAM,QAAQ,KAAKD,KAAK,CAAC,KAAK,MAAM,KAAK,EAAE,MAAM,SAAS,CAAC,GAC7D,MAAM,KAAKN,OAAO,OAAO,MAAM,OAAO,CAAC;EAEzC,OAAO;CACT;;;;;;;CAQA,MAAM,qBAAqB,EACzB,UACA,OACA,kBAAkB,KAAKC,qBAcrB,CAAC,GAAyC;EAC5C,MAAM,cAAc,iBAAiB,KAAKI,gBAAgB;EAC1D,MAAM,SAAsB;GAC1B,aAAa,mBAAmB;GAEhC,WAAW,kBAAkB,WAAW;GACxC,QAAQ;GACR,WAAW,KAAKC,KAAK,IAAI,KAAKH,oBAAoB;GAClD,UAAU,KAAKC;GACf;GACA;EACF;EACA,MAAM,KAAKJ,OAAO,OAAO,MAAM;EAC/B,MAAM,KAAKA,OAAO,QAAQ,KAAKM,KAAK,CAAC;EAErC,OAAO;GACL,aAAa,OAAO;GAEpB,WAAW;GACX,kBAAkB;GAClB,2BAA2B,KAAKJ,8BAC9B,aACA,eACF;GACA,YAAY,KAAKC;GACjB,UAAU,KAAKC;EACjB;CACF;;;;;;;;;CAUA,MAAM,KAAK,YAAyC;EAClD,MAAM,QAAQ,MAAM,KAAKI,MACvB,MAAM,KAAKR,OAAO,iBAAiB,UAAU,CAC/C;EAGA,IAAI,CAAC,OAAO,OAAO;GAAE,QAAQ;GAAW,OAAO;EAAgB;EAE/D,QAAQ,MAAM,OAAd;GACE,KAAK,UACH,OAAO;IAAE,QAAQ;IAAU,OAAO;GAAgB;GAGpD,KAAK;GACL,KAAK,WACH,OAAO;IAAE,QAAQ;IAAW,OAAO;GAAgB;GACrD,KAAK,YAAY;IAIf,MAAM,EAAE,YAAY;IACpB,MAAM,KAAK,EAAE,MAAM,SAAS,CAAC;IAC7B,MAAM,KAAKA,OAAO,OAAO,MAAM,OAAO,CAAC;IACvC,OAAO;KAAE,QAAQ;KAAY;IAAQ;GACvC;GACA,KAAK,WAAW;IACd,MAAM,KAAK,KAAKM,KAAK;IACrB,IAAI,MAAM,eAAe,EAAE,GAAG;KAC5B,MAAM,WAAW,MAAM,SAAS,EAAE;KAClC,MAAM,KAAKN,OAAO,OAAO,MAAM,OAAO,CAAC;KACvC,OAAO;MAAE,QAAQ;MAAa,OAAO;MAAa;KAAS;IAC7D;IACA,MAAM,WAAW,EAAE;IACnB,MAAM,KAAKA,OAAO,OAAO,MAAM,OAAO,CAAC;IACvC,OAAO;KAAE,QAAQ;KAAW,OAAO;IAAwB;GAC7D;EACF;CACF;;;;;;;;;CAUA,MAAM,iBAAiB,OAA4C;EAIjE,QAAO,MAHa,KAAKQ,MACvB,MAAM,KAAKR,OAAO,eAAe,kBAAkB,KAAK,CAAC,CAC3D,EAAA,EACc,OAAO,KAAK;CAC5B;;CAGA,MAAM,QAAQ,OAAe,SAAyC;EACpE,OAAO,KAAKS,QAAQ,OAAO;GAAE,MAAM;GAAW;EAAQ,CAAC;CACzD;;CAGA,MAAM,KAAK,OAAuC;EAChD,OAAO,KAAKA,QAAQ,OAAO,EAAE,MAAM,OAAO,CAAC;CAC7C;;;;;;;;;CAUA,MAAMA,QACJ,OACA,OACwB;EACxB,MAAM,QAAQ,MAAM,KAAKD,MACvB,MAAM,KAAKR,OAAO,eAAe,kBAAkB,KAAK,CAAC,CAC3D;EACA,IAAI,CAAC,OAAO,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAY;EAEpD,IAAI,CAAC,MAAM,KAAK,KAAK,GACnB,OAAO;GACL,IAAI;GACJ,QAAQ,UAAU,MAAM,KAAK;GAC7B,OAAO,MAAM,OAAO;EACtB;EAGF,MAAM,WAAW,MAAM,OAAO;EAC9B,MAAM,KAAKA,OAAO,OAAO,QAAQ;EACjC,OAAO;GAAE,IAAI;GAAM,OAAO;EAAS;CACrC;AACF;;AAGA,MAAM,aAAa,UACjB,UAAU,YAAY,YAAY;;;;;;;AAQpC,MAAa,qBAAqB,YAChC,IAAI,YAAY,OAAO"}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
//#region src/linking.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build the URL a QR should encode.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately an `https://` URL rather than a scheme: this is the same string
|
|
6
|
+
* `verification_uri_complete` already carries, which is what lets one QR serve
|
|
7
|
+
* a phone with the app, a phone without it, and a laptop.
|
|
8
|
+
*/
|
|
9
|
+
const buildApprovalUrl = (userCode, { origin, path = `/link`, codeParam = `user_code` }) => {
|
|
10
|
+
const url = new URL(path, origin);
|
|
11
|
+
url.searchParams.set(codeParam, userCode);
|
|
12
|
+
return url.toString();
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Build the custom-scheme equivalent.
|
|
16
|
+
*
|
|
17
|
+
* For a "Open in app" button on the web fallback page — a deliberate tap, where
|
|
18
|
+
* a failure is recoverable because the user is already looking at a working web
|
|
19
|
+
* page. Never for the QR itself.
|
|
20
|
+
*/
|
|
21
|
+
const buildAppSchemeUrl = (userCode, { scheme, path = `/link`, codeParam = `user_code` }) => {
|
|
22
|
+
if (scheme === void 0) throw new Error(`no custom scheme configured`);
|
|
23
|
+
const url = new URL(`${scheme}://${path.replace(/^\//u, ``)}`);
|
|
24
|
+
url.searchParams.set(codeParam, userCode);
|
|
25
|
+
return url.toString();
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Parse an inbound link, however it arrived.
|
|
29
|
+
*
|
|
30
|
+
* One entry point for every route into the approval screen: an Expo
|
|
31
|
+
* `Linking.getInitialURL()`, a PWA `launchQueue` target, or `location.href`.
|
|
32
|
+
* Returning the `source` lets a host tell "the OS routed this to us" from "the
|
|
33
|
+
* user is on the web page", which changes what the UI should offer.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Read a code out of a path-style link (`/link/WDJB-MJHT`).
|
|
37
|
+
*
|
|
38
|
+
* A web URL needs a segment BEYOND the route itself. `/link` alone would
|
|
39
|
+
* otherwise yield the code "link" — which the server rejects, so an ordinary
|
|
40
|
+
* visit to the approval page reports itself as a failed code. That shipped
|
|
41
|
+
* once: it showed "that code is not valid" on load and tore down the camera
|
|
42
|
+
* that was mid-startup.
|
|
43
|
+
*
|
|
44
|
+
* A custom-scheme link has no route to strip — `hanko://WDJB-MJHT` carries the
|
|
45
|
+
* code as its only segment — so one segment is enough there.
|
|
46
|
+
*/
|
|
47
|
+
const pathCode = (url, isCustomScheme) => {
|
|
48
|
+
const segments = url.pathname.split(`/`).filter(Boolean);
|
|
49
|
+
if (isCustomScheme && segments.length === 0) return url.hostname.length > 0 ? url.hostname : void 0;
|
|
50
|
+
return segments.length >= (isCustomScheme ? 1 : 2) ? segments[segments.length - 1] : void 0;
|
|
51
|
+
};
|
|
52
|
+
const parseApprovalLink = (href, { codeParam = `user_code`, scheme } = {}) => {
|
|
53
|
+
let url;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(href);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const isCustomScheme = scheme !== void 0 && url.protocol === `${scheme}:`;
|
|
60
|
+
if (!isCustomScheme && url.protocol !== `https:` && url.protocol !== `http:`) return null;
|
|
61
|
+
const userCode = url.searchParams.get(codeParam) ?? pathCode(url, isCustomScheme);
|
|
62
|
+
if (userCode === void 0 || userCode.length === 0) return null;
|
|
63
|
+
return {
|
|
64
|
+
userCode,
|
|
65
|
+
href,
|
|
66
|
+
source: isCustomScheme ? `custom-scheme` : `web`
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Apple App Site Association, for `/.well-known/apple-app-site-association`.
|
|
71
|
+
*
|
|
72
|
+
* Serve as `application/json` over HTTPS with **no redirects** — Apple fetches
|
|
73
|
+
* it directly and a redirect makes the association fail silently, which is the
|
|
74
|
+
* single most common reason universal links "just don't work".
|
|
75
|
+
*
|
|
76
|
+
* @param appIds `<TEAM_ID>.<BUNDLE_ID>`, e.g. `QQ57RJ5UTD.gg.saeris.beerjournal`
|
|
77
|
+
*/
|
|
78
|
+
const appleAppSiteAssociation = (appIds, { paths = [`/link`, `/link/*`] } = {}) => ({ applinks: {
|
|
79
|
+
apps: [],
|
|
80
|
+
details: appIds.map((appID) => ({
|
|
81
|
+
appID,
|
|
82
|
+
paths
|
|
83
|
+
}))
|
|
84
|
+
} });
|
|
85
|
+
/**
|
|
86
|
+
* Digital Asset Links, for `/.well-known/assetlinks.json`.
|
|
87
|
+
*
|
|
88
|
+
* @param fingerprints SHA-256 of the app's SIGNING certificate. Note that Play
|
|
89
|
+
* App Signing re-signs the upload, so the fingerprint that works in
|
|
90
|
+
* production is the one from the Play Console — not your local keystore. A
|
|
91
|
+
* local-only fingerprint is why app links commonly work in debug and break
|
|
92
|
+
* after release.
|
|
93
|
+
*/
|
|
94
|
+
const digitalAssetLinks = (packageName, fingerprints) => [{
|
|
95
|
+
relation: [`delegate_permission/common.handle_all_urls`],
|
|
96
|
+
target: {
|
|
97
|
+
namespace: `android_app`,
|
|
98
|
+
package_name: packageName,
|
|
99
|
+
sha256_cert_fingerprints: fingerprints
|
|
100
|
+
}
|
|
101
|
+
}];
|
|
102
|
+
/**
|
|
103
|
+
* Expo app config fragment for universal/app links.
|
|
104
|
+
*
|
|
105
|
+
* Merge into `app.json`. Requires a development or production build — the
|
|
106
|
+
* entitlement is registered at build time, so **universal links do not work in
|
|
107
|
+
* Expo Go**, and a project pinned to Expo Go must use the web fallback until it
|
|
108
|
+
* moves to dev builds.
|
|
109
|
+
*/
|
|
110
|
+
const expoLinkingConfig = ({ origin, path = `/link`, scheme }) => {
|
|
111
|
+
const { host } = new URL(origin);
|
|
112
|
+
return {
|
|
113
|
+
...scheme === void 0 ? {} : { scheme },
|
|
114
|
+
ios: { associatedDomains: [`applinks:${host}`] },
|
|
115
|
+
android: { intentFilters: [{
|
|
116
|
+
action: `VIEW`,
|
|
117
|
+
autoVerify: true,
|
|
118
|
+
data: [{
|
|
119
|
+
scheme: `https`,
|
|
120
|
+
host,
|
|
121
|
+
pathPrefix: path
|
|
122
|
+
}],
|
|
123
|
+
category: [`BROWSABLE`, `DEFAULT`]
|
|
124
|
+
}] }
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* `launch_handler` fragment for a PWA manifest.
|
|
129
|
+
*
|
|
130
|
+
* `navigate-existing` so a scanned link reuses the already-open window rather
|
|
131
|
+
* than stacking a second one. An approval screen that opened behind the window
|
|
132
|
+
* the user was already looking at would appear not to have worked at all.
|
|
133
|
+
*
|
|
134
|
+
* Pair with `window.launchQueue.setConsumer()` to read the target URL — see
|
|
135
|
+
* {@link consumeLaunchTarget}.
|
|
136
|
+
*/
|
|
137
|
+
const pwaLaunchHandler = () => ({ launch_handler: { client_mode: `navigate-existing` } });
|
|
138
|
+
/**
|
|
139
|
+
* Read the URL an installed PWA was launched with.
|
|
140
|
+
*
|
|
141
|
+
* With `navigate-existing`, the window is reused and `location.href` may
|
|
142
|
+
* already be correct — but when the app was cold-started or the consumer runs
|
|
143
|
+
* before navigation settles, `launchQueue` is the only reliable source.
|
|
144
|
+
*
|
|
145
|
+
* Where `launchQueue` is unsupported — Safari and Firefox, as of 2026 — this
|
|
146
|
+
* calls back with the current location ONLY when `fallbackToLocation` is set.
|
|
147
|
+
* It defaults to false because the fallback cannot tell a launch from an
|
|
148
|
+
* ordinary page load: it fires on every visit, and a caller that treats the
|
|
149
|
+
* result as "the user just arrived from a link" will act on a URL nobody
|
|
150
|
+
* followed. Opt in when the page is only ever reached by launch.
|
|
151
|
+
*/
|
|
152
|
+
const consumeLaunchTarget = (onTarget, { currentHref, fallbackToLocation = false } = {}) => {
|
|
153
|
+
const scope = globalThis;
|
|
154
|
+
const location = scope.location;
|
|
155
|
+
const currentLocationHref = typeof location === `object` && location !== null && `href` in location && typeof location.href === `string` ? location.href : void 0;
|
|
156
|
+
const fallback = currentHref ?? currentLocationHref;
|
|
157
|
+
const queue = scope.launchQueue;
|
|
158
|
+
if (!isLaunchQueue(queue)) {
|
|
159
|
+
if (fallbackToLocation && fallback !== void 0) onTarget(fallback);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
queue.setConsumer(({ targetURL }) => {
|
|
163
|
+
const href = targetURL ?? fallback;
|
|
164
|
+
if (href !== void 0) onTarget(href);
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Structural check for the Launch Handler API.
|
|
169
|
+
*
|
|
170
|
+
* A predicate rather than a cast: the API is absent on every server runtime and
|
|
171
|
+
* in Safari and Firefox, so its presence is a runtime fact to test.
|
|
172
|
+
*/
|
|
173
|
+
const isLaunchQueue = (value) => typeof value === `object` && value !== null && `setConsumer` in value && typeof value.setConsumer === `function`;
|
|
174
|
+
//#endregion
|
|
175
|
+
export { digitalAssetLinks as a, pwaLaunchHandler as c, consumeLaunchTarget as i, buildAppSchemeUrl as n, expoLinkingConfig as o, buildApprovalUrl as r, parseApprovalLink as s, appleAppSiteAssociation as t };
|
|
176
|
+
|
|
177
|
+
//# sourceMappingURL=linking-DcQSMgem.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"linking-DcQSMgem.mjs","names":[],"sources":["../src/linking.ts"],"sourcesContent":["/**\n * App-opening for scanned QR codes.\n *\n * The goal: one QR payload that opens a native app when it is installed, and a\n * web page when it is not — without the device ever showing an error.\n *\n * That rules out custom schemes as the primary target. A `myapp://` QR read by\n * the OS camera on a phone without the app fails silently and\n * unrecoverably — the user sees \"cannot open\" and has nowhere to go. Universal\n * Links (iOS) and App Links (Android) solve this by making the payload an\n * ordinary `https://` URL that the OS *routes* to the app when the domain and\n * app are associated, and to the browser when they are not.\n *\n * So the QR keeps encoding `verification_uri_complete` exactly as before. The\n * routing lives in association files served from the same origin, not in a\n * different payload. What this module provides is the association files, the\n * URL parsing on the receiving end, and a custom-scheme fallback for the cases\n * that genuinely need one.\n */\n\n/** Where an inbound approval link came from. */\nexport type LinkSource =\n /** A Universal Link / App Link that opened the native app. */\n | `app-link`\n /** A custom scheme (`myapp://`). Only reached when explicitly used. */\n | `custom-scheme`\n /** An ordinary web navigation — the app was not installed, or this is a PWA. */\n | `web`;\n\nexport interface ParsedApprovalLink {\n userCode: string;\n source: LinkSource;\n /** The full URL, for logging or to hand to a router. */\n href: string;\n}\n\nexport interface LinkConfig {\n /**\n * Origin serving the approval page, e.g. `https://example.com`.\n * Must be HTTPS: both Apple and Google refuse to associate a plain-HTTP domain.\n */\n origin: string;\n /** Path of the approval page. Also the path the association files claim. */\n path?: string;\n /** Query parameter carrying the code. */\n codeParam?: string;\n /**\n * Custom scheme, e.g. `beerjournal`. Optional and NOT the primary path.\n *\n * Worth registering anyway: it is the only way to reach the app from\n * contexts that refuse to follow universal links — some in-app browsers, and\n * a few QR readers that strip them.\n */\n scheme?: string;\n}\n\n/**\n * Build the URL a QR should encode.\n *\n * Deliberately an `https://` URL rather than a scheme: this is the same string\n * `verification_uri_complete` already carries, which is what lets one QR serve\n * a phone with the app, a phone without it, and a laptop.\n */\nexport const buildApprovalUrl = (\n userCode: string,\n { origin, path = `/link`, codeParam = `user_code` }: LinkConfig\n): string => {\n const url = new URL(path, origin);\n url.searchParams.set(codeParam, userCode);\n return url.toString();\n};\n\n/**\n * Build the custom-scheme equivalent.\n *\n * For a \"Open in app\" button on the web fallback page — a deliberate tap, where\n * a failure is recoverable because the user is already looking at a working web\n * page. Never for the QR itself.\n */\nexport const buildAppSchemeUrl = (\n userCode: string,\n { scheme, path = `/link`, codeParam = `user_code` }: LinkConfig\n): string => {\n if (scheme === undefined) throw new Error(`no custom scheme configured`);\n const url = new URL(`${scheme}://${path.replace(/^\\//u, ``)}`);\n url.searchParams.set(codeParam, userCode);\n return url.toString();\n};\n\n/**\n * Parse an inbound link, however it arrived.\n *\n * One entry point for every route into the approval screen: an Expo\n * `Linking.getInitialURL()`, a PWA `launchQueue` target, or `location.href`.\n * Returning the `source` lets a host tell \"the OS routed this to us\" from \"the\n * user is on the web page\", which changes what the UI should offer.\n */\n/**\n * Read a code out of a path-style link (`/link/WDJB-MJHT`).\n *\n * A web URL needs a segment BEYOND the route itself. `/link` alone would\n * otherwise yield the code \"link\" — which the server rejects, so an ordinary\n * visit to the approval page reports itself as a failed code. That shipped\n * once: it showed \"that code is not valid\" on load and tore down the camera\n * that was mid-startup.\n *\n * A custom-scheme link has no route to strip — `hanko://WDJB-MJHT` carries the\n * code as its only segment — so one segment is enough there.\n */\nconst pathCode = (url: URL, isCustomScheme: boolean): string | undefined => {\n const segments = url.pathname.split(`/`).filter(Boolean);\n // `hanko://WDJB-MJHT` parses the code as the HOST, leaving no path at all.\n if (isCustomScheme && segments.length === 0) {\n return url.hostname.length > 0 ? url.hostname : undefined;\n }\n return segments.length >= (isCustomScheme ? 1 : 2)\n ? segments[segments.length - 1]\n : undefined;\n};\n\nexport const parseApprovalLink = (\n href: string,\n { codeParam = `user_code`, scheme }: Partial<LinkConfig> = {}\n): ParsedApprovalLink | null => {\n let url: URL;\n try {\n url = new URL(href);\n } catch {\n return null;\n }\n\n const isCustomScheme = scheme !== undefined && url.protocol === `${scheme}:`;\n if (\n !isCustomScheme &&\n url.protocol !== `https:` &&\n url.protocol !== `http:`\n ) {\n return null;\n }\n\n const userCode =\n url.searchParams.get(codeParam) ?? pathCode(url, isCustomScheme);\n\n if (userCode === undefined || userCode.length === 0) return null;\n\n return {\n userCode,\n href,\n source: isCustomScheme ? `custom-scheme` : `web`\n };\n};\n\n/**\n * Apple App Site Association, for `/.well-known/apple-app-site-association`.\n *\n * Serve as `application/json` over HTTPS with **no redirects** — Apple fetches\n * it directly and a redirect makes the association fail silently, which is the\n * single most common reason universal links \"just don't work\".\n *\n * @param appIds `<TEAM_ID>.<BUNDLE_ID>`, e.g. `QQ57RJ5UTD.gg.saeris.beerjournal`\n */\nexport const appleAppSiteAssociation = (\n appIds: string[],\n { paths = [`/link`, `/link/*`] }: { paths?: string[] } = {}\n): object => ({\n applinks: {\n // Required by the schema and must stay empty — Apple deprecated its use.\n apps: [],\n details: appIds.map((appID) => ({ appID, paths }))\n }\n});\n\n/**\n * Digital Asset Links, for `/.well-known/assetlinks.json`.\n *\n * @param fingerprints SHA-256 of the app's SIGNING certificate. Note that Play\n * App Signing re-signs the upload, so the fingerprint that works in\n * production is the one from the Play Console — not your local keystore. A\n * local-only fingerprint is why app links commonly work in debug and break\n * after release.\n */\nexport const digitalAssetLinks = (\n packageName: string,\n fingerprints: string[]\n): object[] => [\n {\n relation: [`delegate_permission/common.handle_all_urls`],\n target: {\n namespace: `android_app`,\n package_name: packageName,\n sha256_cert_fingerprints: fingerprints\n }\n }\n];\n\n/**\n * Expo app config fragment for universal/app links.\n *\n * Merge into `app.json`. Requires a development or production build — the\n * entitlement is registered at build time, so **universal links do not work in\n * Expo Go**, and a project pinned to Expo Go must use the web fallback until it\n * moves to dev builds.\n */\nexport const expoLinkingConfig = ({\n origin,\n path = `/link`,\n scheme\n}: LinkConfig): object => {\n const { host } = new URL(origin);\n return {\n // Custom scheme, for the deliberate \"open in app\" tap.\n ...(scheme === undefined ? {} : { scheme }),\n ios: {\n // No protocol, per Apple's format — including `https://` here is a\n // silent misconfiguration.\n associatedDomains: [`applinks:${host}`]\n },\n android: {\n intentFilters: [\n {\n action: `VIEW`,\n // `autoVerify` is what makes Android check assetlinks.json and open\n // the app WITHOUT a chooser dialog. Without it the user gets a\n // \"open with\" prompt every time, which reads as broken.\n autoVerify: true,\n data: [{ scheme: `https`, host, pathPrefix: path }],\n category: [`BROWSABLE`, `DEFAULT`]\n }\n ]\n }\n };\n};\n\n/**\n * `launch_handler` fragment for a PWA manifest.\n *\n * `navigate-existing` so a scanned link reuses the already-open window rather\n * than stacking a second one. An approval screen that opened behind the window\n * the user was already looking at would appear not to have worked at all.\n *\n * Pair with `window.launchQueue.setConsumer()` to read the target URL — see\n * {@link consumeLaunchTarget}.\n */\nexport const pwaLaunchHandler = (): object => ({\n launch_handler: { client_mode: `navigate-existing` }\n});\n\n/**\n * Read the URL an installed PWA was launched with.\n *\n * With `navigate-existing`, the window is reused and `location.href` may\n * already be correct — but when the app was cold-started or the consumer runs\n * before navigation settles, `launchQueue` is the only reliable source.\n *\n * Where `launchQueue` is unsupported — Safari and Firefox, as of 2026 — this\n * calls back with the current location ONLY when `fallbackToLocation` is set.\n * It defaults to false because the fallback cannot tell a launch from an\n * ordinary page load: it fires on every visit, and a caller that treats the\n * result as \"the user just arrived from a link\" will act on a URL nobody\n * followed. Opt in when the page is only ever reached by launch.\n */\nexport const consumeLaunchTarget = (\n onTarget: (href: string) => void,\n {\n currentHref,\n fallbackToLocation = false\n }: { currentHref?: string; fallbackToLocation?: boolean } = {}\n): void => {\n // Narrowed by runtime checks rather than a cast: neither `launchQueue` nor\n // `location` exists on a server runtime, and `launchQueue` is absent in\n // Safari and Firefox even in a browser.\n const scope: Record<string, unknown> = globalThis;\n\n const location = scope.location;\n const currentLocationHref =\n typeof location === `object` &&\n location !== null &&\n `href` in location &&\n typeof location.href === `string`\n ? location.href\n : undefined;\n const fallback = currentHref ?? currentLocationHref;\n\n const queue = scope.launchQueue;\n if (!isLaunchQueue(queue)) {\n // Opt-in only. Firing here on every load would report an ordinary visit as\n // a launch, and a caller acting on that URL acts on something nobody\n // followed.\n if (fallbackToLocation && fallback !== undefined) onTarget(fallback);\n return;\n }\n\n queue.setConsumer(({ targetURL }) => {\n const href = targetURL ?? fallback;\n if (href !== undefined) onTarget(href);\n });\n};\n\n/** The subset of `LaunchQueue` this module uses. */\ninterface LaunchQueueLike {\n setConsumer: (fn: (params: { targetURL?: string }) => void) => void;\n}\n\n/**\n * Structural check for the Launch Handler API.\n *\n * A predicate rather than a cast: the API is absent on every server runtime and\n * in Safari and Firefox, so its presence is a runtime fact to test.\n */\nconst isLaunchQueue = (value: unknown): value is LaunchQueueLike =>\n typeof value === `object` &&\n value !== null &&\n `setConsumer` in value &&\n typeof value.setConsumer === `function`;\n"],"mappings":";;;;;;;;AA+DA,MAAa,oBACX,UACA,EAAE,QAAQ,OAAO,SAAS,YAAY,kBAC3B;CACX,MAAM,MAAM,IAAI,IAAI,MAAM,MAAM;CAChC,IAAI,aAAa,IAAI,WAAW,QAAQ;CACxC,OAAO,IAAI,SAAS;AACtB;;;;;;;;AASA,MAAa,qBACX,UACA,EAAE,QAAQ,OAAO,SAAS,YAAY,kBAC3B;CACX,IAAI,WAAW,KAAA,GAAW,MAAM,IAAI,MAAM,6BAA6B;CACvE,MAAM,MAAM,IAAI,IAAI,GAAG,OAAO,KAAK,KAAK,QAAQ,QAAQ,EAAE,GAAG;CAC7D,IAAI,aAAa,IAAI,WAAW,QAAQ;CACxC,OAAO,IAAI,SAAS;AACtB;;;;;;;;;;;;;;;;;;;;;AAsBA,MAAM,YAAY,KAAU,mBAAgD;CAC1E,MAAM,WAAW,IAAI,SAAS,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO;CAEvD,IAAI,kBAAkB,SAAS,WAAW,GACxC,OAAO,IAAI,SAAS,SAAS,IAAI,IAAI,WAAW,KAAA;CAElD,OAAO,SAAS,WAAW,iBAAiB,IAAI,KAC5C,SAAS,SAAS,SAAS,KAC3B,KAAA;AACN;AAEA,MAAa,qBACX,MACA,EAAE,YAAY,aAAa,WAAgC,CAAC,MAC9B;CAC9B,IAAI;CACJ,IAAI;EACF,MAAM,IAAI,IAAI,IAAI;CACpB,QAAQ;EACN,OAAO;CACT;CAEA,MAAM,iBAAiB,WAAW,KAAA,KAAa,IAAI,aAAa,GAAG,OAAO;CAC1E,IACE,CAAC,kBACD,IAAI,aAAa,YACjB,IAAI,aAAa,SAEjB,OAAO;CAGT,MAAM,WACJ,IAAI,aAAa,IAAI,SAAS,KAAK,SAAS,KAAK,cAAc;CAEjE,IAAI,aAAa,KAAA,KAAa,SAAS,WAAW,GAAG,OAAO;CAE5D,OAAO;EACL;EACA;EACA,QAAQ,iBAAiB,kBAAkB;CAC7C;AACF;;;;;;;;;;AAWA,MAAa,2BACX,QACA,EAAE,QAAQ,CAAC,SAAS,SAAS,MAA4B,CAAC,OAC9C,EACZ,UAAU;CAER,MAAM,CAAC;CACP,SAAS,OAAO,KAAK,WAAW;EAAE;EAAO;CAAM,EAAE;AACnD,EACF;;;;;;;;;;AAWA,MAAa,qBACX,aACA,iBACa,CACb;CACE,UAAU,CAAC,4CAA4C;CACvD,QAAQ;EACN,WAAW;EACX,cAAc;EACd,0BAA0B;CAC5B;AACF,CACF;;;;;;;;;AAUA,MAAa,qBAAqB,EAChC,QACA,OAAO,SACP,aACwB;CACxB,MAAM,EAAE,SAAS,IAAI,IAAI,MAAM;CAC/B,OAAO;EAEL,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;EACzC,KAAK,EAGH,mBAAmB,CAAC,YAAY,MAAM,EACxC;EACA,SAAS,EACP,eAAe,CACb;GACE,QAAQ;GAIR,YAAY;GACZ,MAAM,CAAC;IAAE,QAAQ;IAAS;IAAM,YAAY;GAAK,CAAC;GAClD,UAAU,CAAC,aAAa,SAAS;EACnC,CACF,EACF;CACF;AACF;;;;;;;;;;;AAYA,MAAa,0BAAkC,EAC7C,gBAAgB,EAAE,aAAa,oBAAoB,EACrD;;;;;;;;;;;;;;;AAgBA,MAAa,uBACX,UACA,EACE,aACA,qBAAqB,UACqC,CAAC,MACpD;CAIT,MAAM,QAAiC;CAEvC,MAAM,WAAW,MAAM;CACvB,MAAM,sBACJ,OAAO,aAAa,YACpB,aAAa,QACb,UAAU,YACV,OAAO,SAAS,SAAS,WACrB,SAAS,OACT,KAAA;CACN,MAAM,WAAW,eAAe;CAEhC,MAAM,QAAQ,MAAM;CACpB,IAAI,CAAC,cAAc,KAAK,GAAG;EAIzB,IAAI,sBAAsB,aAAa,KAAA,GAAW,SAAS,QAAQ;EACnE;CACF;CAEA,MAAM,aAAa,EAAE,gBAAgB;EACnC,MAAM,OAAO,aAAa;EAC1B,IAAI,SAAS,KAAA,GAAW,SAAS,IAAI;CACvC,CAAC;AACH;;;;;;;AAaA,MAAM,iBAAiB,UACrB,OAAO,UAAU,YACjB,UAAU,QACV,iBAAiB,SACjB,OAAO,MAAM,gBAAgB"}
|