From fed9827eaf29fbd4733f10db090b3e7c11d27101 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Tue, 18 Aug 2026 00:08:30 -0400 Subject: [PATCH] feat: the approval link carries the code hyper's verify page reads ?user_code into the field, so opening it can be the whole job. The code stays on screen for approving on another device, and the hint says which of the two you are doing. --- src/client/views/connections.js | 8 ++++++-- src/hyperauth.ts | 24 +++++++++++++++++++++++- tests/credentials.test.ts | 5 +++++ 3 files changed, 34 insertions(+), 3 deletions(-) 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; -- 2.51.2