Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions e2e/regression/companion-handshake.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?")
Expand All @@ -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();
Expand All @@ -243,21 +247,25 @@ 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");
await page.getByLabel("Password").fill("password123");
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();
}
Expand Down
25 changes: 25 additions & 0 deletions scripts/verify-auth-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"), "/");
});
30 changes: 30 additions & 0 deletions scripts/verify-companion-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/lib/companion-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading