diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e4619a..be53ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Graduation: account-gated "Save to Minutia" turns retro action items into tracked issues in a new or existing series; free Markdown export needs no account. - `retro_enabled` instance flag (default off; admin toggle in workspace settings) gates the public board surface for self-host instances. +### Changed + +- The companion authorize page now echoes the desktop app's state nonce on the minutia:// callback, letting the companion bind each sign-in callback to the attempt that initiated it. + ## [1.1.0] - 2026-05-03 ### Changed diff --git a/e2e/regression/companion-handshake.spec.ts b/e2e/regression/companion-handshake.spec.ts index 91212f4..735c15c 100644 --- a/e2e/regression/companion-handshake.spec.ts +++ b/e2e/regression/companion-handshake.spec.ts @@ -204,7 +204,10 @@ test.describe("companion handshake", () => { }) => { test.setTimeout(60_000); - await page.goto("/companion/authorize?device=Test%20Mac"); + const stateNonce = randomUUID(); + await page.goto( + `/companion/authorize?device=Test%20Mac&state=${stateNonce}` + ); await waitForApp(page); await expect( page.getByText("Authorize the Minutia companion app on Test Mac?") @@ -217,15 +220,16 @@ test.describe("companion handshake", () => { const href = await openLink.getAttribute("href"); expect(href).toMatch(/^minutia:\/\/auth-callback\?token_hash=/); - const tokenHash = decodeURIComponent( - new URL(href!).search.replace("?token_hash=", "") - ); - expect(tokenHash.length).toBeGreaterThan(0); + const url = new URL(href!); + expect(url.searchParams.get("state")).toBe(stateNonce); + + const tokenHash = url.searchParams.get("token_hash"); + expect(tokenHash?.length).toBeGreaterThan(0); // Prove the token is real: run the exact GoTrue exchange the desktop app runs. const verify = await request.post(`${SUPABASE_URL}/auth/v1/verify`, { headers: { apikey: ANON_KEY, "Content-Type": "application/json" }, - data: { type: "magiclink", token_hash: tokenHash }, + data: { type: "magiclink", token_hash: tokenHash! }, }); expect(verify.ok()).toBeTruthy(); const session = await verify.json(); @@ -243,7 +247,10 @@ test.describe("companion handshake", () => { }); const page = await context.newPage(); try { - await page.goto("/companion/authorize?device=Test%20Mac"); + const stateNonce = randomUUID(); + await page.goto( + `/companion/authorize?device=Test%20Mac&state=${stateNonce}` + ); await expect(page).toHaveURL(/\/login\?.*next=%2Fcompanion%2Fauthorize/); await page.getByLabel("Email address").fill("test@example.com"); @@ -251,13 +258,14 @@ test.describe("companion handshake", () => { await page.getByRole("button", { name: "Sign in", exact: true }).click(); // The existing next/redirect handling returns to the authorize page with - // the device query intact. + // the device and state query intact. await expect( page.getByText("Authorize the Minutia companion app on Test Mac?") ).toBeVisible({ timeout: 15_000 }); const returned = new URL(page.url()); expect(returned.pathname).toBe("/companion/authorize"); expect(returned.searchParams.get("device")).toBe("Test Mac"); + expect(returned.searchParams.get("state")).toBe(stateNonce); } finally { await context.close(); } diff --git a/scripts/verify-auth-links.test.mjs b/scripts/verify-auth-links.test.mjs index 7b9bc6e..66fcb52 100644 --- a/scripts/verify-auth-links.test.mjs +++ b/scripts/verify-auth-links.test.mjs @@ -88,3 +88,28 @@ test("returns the link unchanged when NEXT_PUBLIC_SUPABASE_URL is unset", () => assert.equal(toPublicActionLink(link), link); }); }); + +const authCallbackBundle = path.join(tempDir, "auth-callback-url.mjs"); +await esbuild.build({ + entryPoints: ["src/lib/auth-callback-url.ts"], + outfile: authCallbackBundle, + bundle: true, + platform: "node", + format: "esm", + logLevel: "silent", + absWorkingDir: root, +}); +const { safeAuthNextPath } = await import( + pathToFileURL(authCallbackBundle).href +); + +test("safeAuthNextPath passes a companion authorize path with device and state through verbatim", () => { + const next = "/companion/authorize?device=Test%20Mac&state=nonce-1"; + assert.equal(safeAuthNextPath(next), next); +}); + +test("safeAuthNextPath rejects non-path and protocol-relative values", () => { + assert.equal(safeAuthNextPath(null), "/"); + assert.equal(safeAuthNextPath("https://evil.example"), "/"); + assert.equal(safeAuthNextPath("//evil.example/companion/authorize"), "/"); +}); diff --git a/scripts/verify-companion-links.test.mjs b/scripts/verify-companion-links.test.mjs index 9998ef1..9d25de4 100644 --- a/scripts/verify-companion-links.test.mjs +++ b/scripts/verify-companion-links.test.mjs @@ -40,6 +40,36 @@ test("buildCompanionAuthCallbackUrl rejects an empty token hash", () => { assert.throws(() => buildCompanionAuthCallbackUrl(" ")); }); +test("buildCompanionAuthCallbackUrl appends the state nonce when present", () => { + assert.equal( + buildCompanionAuthCallbackUrl("abc123", "nonce-1"), + "minutia://auth-callback?token_hash=abc123&state=nonce-1" + ); + assert.equal( + buildCompanionAuthCallbackUrl("abc123", "a b+c/d"), + "minutia://auth-callback?token_hash=abc123&state=a%20b%2Bc%2Fd" + ); + assert.equal( + buildCompanionAuthCallbackUrl("abc123", "a&b=c#d"), + "minutia://auth-callback?token_hash=abc123&state=a%26b%3Dc%23d" + ); +}); + +test("buildCompanionAuthCallbackUrl omits state when absent or empty", () => { + assert.equal( + buildCompanionAuthCallbackUrl("abc123"), + "minutia://auth-callback?token_hash=abc123" + ); + assert.equal( + buildCompanionAuthCallbackUrl("abc123", null), + "minutia://auth-callback?token_hash=abc123" + ); + assert.equal( + buildCompanionAuthCallbackUrl("abc123", ""), + "minutia://auth-callback?token_hash=abc123" + ); +}); + test("buildCompanionRecordUrl builds the record scheme with the meeting id", () => { assert.equal( buildCompanionRecordUrl(LOWER), diff --git a/src/app/(app)/companion/authorize/companion-authorize-client.tsx b/src/app/(app)/companion/authorize/companion-authorize-client.tsx index a3fd4f4..144b0f2 100644 --- a/src/app/(app)/companion/authorize/companion-authorize-client.tsx +++ b/src/app/(app)/companion/authorize/companion-authorize-client.tsx @@ -18,6 +18,7 @@ export function CompanionAuthorizeClient() { const searchParams = useSearchParams(); // Rendered as React text content, so it is escaped; never interpolated into markup. const device = searchParams.get("device")?.trim() || "this device"; + const state = searchParams.get("state")?.trim() || null; const [status, setStatus] = React.useState<"idle" | "authorizing" | "done">( "idle" @@ -37,7 +38,7 @@ export function CompanionAuthorizeClient() { return; } const { token_hash } = (await res.json()) as { token_hash: string }; - const url = buildCompanionAuthCallbackUrl(token_hash); + const url = buildCompanionAuthCallbackUrl(token_hash, state); setCallbackUrl(url); setStatus("done"); // Hand off to the desktop app's registered URL scheme. If no handler is diff --git a/src/lib/companion-links.ts b/src/lib/companion-links.ts index 0d29e65..6ceb085 100644 --- a/src/lib/companion-links.ts +++ b/src/lib/companion-links.ts @@ -6,10 +6,17 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -export function buildCompanionAuthCallbackUrl(tokenHash: string): string { +export function buildCompanionAuthCallbackUrl( + tokenHash: string, + state?: string | null +): string { const token = tokenHash?.trim(); if (!token) throw new Error("token_hash is required"); - return `minutia://auth-callback?token_hash=${encodeURIComponent(token)}`; + let url = `minutia://auth-callback?token_hash=${encodeURIComponent(token)}`; + if (state) { + url += `&state=${encodeURIComponent(state)}`; + } + return url; } export function buildCompanionRecordUrl(meetingId: string): string {