diff --git a/src/client/views/connections.js b/src/client/views/connections.js index d359ae0..7652cf5 100644 --- a/src/client/views/connections.js +++ b/src/client/views/connections.js @@ -236,12 +236,16 @@ async function runDeviceFlow(basePath, ctx) { code.textContent = start.userCode; var hint = document.createElement("div"); hint.className = "connhint"; - hint.textContent = "Type this code to approve, then come back. This page is watching for it."; + hint.textContent = start.verificationUrlComplete + ? "Approve it in the tab that opens, then come back. This page is watching for it." + : "Type this code to approve, then come back. This page is watching for it."; var open = document.createElement("a"); open.className = "btn primary"; open.target = "_blank"; open.rel = "noopener"; - open.href = start.verificationUrl; + // The complete link carries the code, so approving is a click rather than a + // transcription. The code stays visible for approving on another device. + open.href = start.verificationUrlComplete || start.verificationUrl; open.textContent = "Open approval page"; var cancel = document.createElement("button"); cancel.type = "button"; diff --git a/src/hyperauth.ts b/src/hyperauth.ts index d53e6ed..9c388bb 100644 --- a/src/hyperauth.ts +++ b/src/hyperauth.ts @@ -24,11 +24,28 @@ const TIMEOUT_MS = 15_000; const trimSlash = (s: string): string => s.replace(/\/+$/, ""); +/** The verification page with the code in its query, or undefined if the URL won't parse. */ +function withUserCode(verificationUrl: string, userCode: string): string | undefined { + try { + const url = new URL(verificationUrl); + url.searchParams.set("user_code", userCode); + return url.toString(); + } catch { + return undefined; + } +} + export interface DeviceStart { deviceCode: string; /** What the user types on the verification page, formatted for reading aloud. */ userCode: string; verificationUrl: string; + /** + * The same page with the code already in the box (RFC 8628 calls this + * `verification_uri_complete`). Where it exists, approving is two clicks and + * no typing; the code stays on screen anyway, for approving on a phone. + */ + verificationUrlComplete?: string; /** Epoch ms after which the code is dead. */ expiresAt: number; } @@ -82,10 +99,15 @@ export async function startDeviceAuth( expires_in?: number; }; if (!data.device_code || !data.user_code) throw new Error("hyper: device auth returned no code"); + const verificationUrl = data.verification_url ?? `${trimSlash(baseUrl)}/device/authed/verify`; return { deviceCode: data.device_code, userCode: data.user_code, - verificationUrl: data.verification_url ?? `${trimSlash(baseUrl)}/device/authed/verify`, + verificationUrl, + // hyper's verify page reads ?user_code and renders it into the field, so + // the link can carry it. It does not advertise a complete URL of its own, + // which is why this is built rather than read. + verificationUrlComplete: withUserCode(verificationUrl, data.user_code), expiresAt: Date.now() + (data.expires_in ?? 900) * 1000, }; } diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index 163b24b..72dbe4e 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -236,6 +236,11 @@ test("the device flow reads hyper's shapes, including its in-body errors", async expect(s.deviceCode).toBe("dc-1"); expect(s.userCode).toBe("ABCD-EFGH"); expect(s.expiresAt).toBeGreaterThan(Date.now()); + // hyper's verify page reads ?user_code into the field, so the link carries + // it and approving costs no typing. + expect(s.verificationUrlComplete).toBe( + "https://hyper.test/device/authed/verify?user_code=ABCD-EFGH", + ); const body = (b: unknown) => (async () => new Response(JSON.stringify(b), { status: 200 })) as unknown as typeof fetch;