feat(desktop): sign in with the browser without typing a code (#3286)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 17:31:45 +02:00
1 parent 510cc1b916
commit c5453f83e8
57 files changed
+7060 -3854

No files matched your search

@@ -97,7 +97,7 @@ function createInstanceClient(routes) {
};
}
function createHandoff(routes, {privileged = true} = {}) {
function createHandoff(routes, {privileged = true, returnUri = null} = {}) {
const document = createDocument({privileged});
const instance = createInstanceClient(routes);
const warnings = [];
@@ -108,6 +108,7 @@ function createHandoff(routes, {privileged = true} = {}) {
onWatcherFailure: (error, reason) => warnings.push({message: reason, args: [error]}),
}),
selectedInstanceClient: instance.client,
returnUri: () => returnUri,
});
return {document, handoff, requests: instance.requests, routes: handoff.ipcRoutes(), warnings};
}
@@ -115,10 +116,19 @@ function createHandoff(routes, {privileged = true} = {}) {
const INITIATE_PATH = '/api/v1/auth/handoff/initiate';
const STATUS_PATH = `/api/v1/auth/handoff/${encodeURIComponent(CODE)}/status`;
function initiateRoute({pollSecret = 'poll-secret-value'} = {}) {
return () => jsonResponse(200, {code: CODE, expires_at: futureTimestamp(), poll_secret: pollSecret});
function initiateRoute({pollSecret = 'poll-secret-value', returnMethod} = {}) {
return () =>
jsonResponse(200, {
code: CODE,
expires_at: futureTimestamp(),
poll_secret: pollSecret,
...(returnMethod == null ? {} : {return_method: returnMethod}),
});
}
const RETURN_URI = 'fluxer://handoff';
const GRANT = 'grant-value_42';
function completedStatus() {
return jsonResponse(200, {status: 'completed', token: 'token-42', user_id: '42', user: COMPLETED_USER});
}
@@ -319,6 +329,85 @@ describe('the main-process browser handoff transport', () => {
});
});
describe('the main-process handoff return through the deep link', () => {
test('asks for a deep link return only when the app can receive one', async () => {
const registered = createHandoff(
{[`POST ${INITIATE_PATH}`]: initiateRoute({returnMethod: 'deep_link'})},
{returnUri: RETURN_URI},
);
const unregistered = createHandoff({[`POST ${INITIATE_PATH}`]: initiateRoute({returnMethod: 'code'})});
const linked = await registered.routes[DESKTOP_HANDOFF_CHANNELS.initiate](registered.document.event, INSTANCE);
const typed = await unregistered.routes[DESKTOP_HANDOFF_CHANNELS.initiate](unregistered.document.event, INSTANCE);
assert.deepEqual(registered.requests[0].body, {return_uri: RETURN_URI});
assert.equal(linked.returnMethod, 'deep_link');
assert.equal(unregistered.requests[0].body, null);
assert.equal(typed.returnMethod, 'code');
});
test('falls back to the code when the instance does not confirm the deep link return', async () => {
const {document, routes} = createHandoff({[`POST ${INITIATE_PATH}`]: initiateRoute()}, {returnUri: RETURN_URI});
const session = await routes[DESKTOP_HANDOFF_CHANNELS.initiate](document.event, INSTANCE);
assert.equal(session.returnMethod, 'code');
});
test('presents the grant from the return link with the poll secret and never hands it to the renderer', async () => {
const {document, handoff, routes, requests} = createHandoff(
{
[`POST ${INITIATE_PATH}`]: initiateRoute({returnMethod: 'deep_link'}),
[`POST ${STATUS_PATH}`]: (call) => (call === 2 ? jsonResponse(200, {status: 'pending'}) : completedStatus()),
},
{returnUri: RETURN_URI},
);
const session = await routes[DESKTOP_HANDOFF_CHANNELS.initiate](document.event, INSTANCE);
assert.deepEqual(await routes[DESKTOP_HANDOFF_CHANNELS.status](document.event, session.code), {status: 'pending'});
handoff.acceptReturnLink(new URL(`${RETURN_URI}?code=${CODE.toLowerCase()}&grant=${GRANT}`));
const result = await routes[DESKTOP_HANDOFF_CHANNELS.status](document.event, session.code);
assert.equal(result.status, 'completed');
assert.deepEqual(requests[1].body, {poll_secret: 'poll-secret-value'});
assert.deepEqual(requests[2].body, {poll_secret: 'poll-secret-value', grant: GRANT});
assert.equal(JSON.stringify(result).includes(GRANT), false);
});
test('ignores a return link for a request this app is not waiting on', async () => {
const {document, handoff, routes, requests, warnings} = createHandoff(
{
[`POST ${INITIATE_PATH}`]: initiateRoute({returnMethod: 'deep_link'}),
[`POST ${STATUS_PATH}`]: () => jsonResponse(200, {status: 'pending'}),
},
{returnUri: RETURN_URI},
);
const session = await routes[DESKTOP_HANDOFF_CHANNELS.initiate](document.event, INSTANCE);
handoff.acceptReturnLink(new URL(`${RETURN_URI}?code=ZZZZZZ-999999&grant=${GRANT}`));
handoff.acceptReturnLink(new URL(`${RETURN_URI}?code=${CODE}&grant=not%20valid`));
await routes[DESKTOP_HANDOFF_CHANNELS.status](document.event, session.code);
assert.deepEqual(requests[1].body, {poll_secret: 'poll-secret-value'});
assert.equal(warnings.length, 2);
});
test('reports a request the browser declined and releases the session', async () => {
const {document, routes} = createHandoff({
[`POST ${INITIATE_PATH}`]: initiateRoute(),
[`POST ${STATUS_PATH}`]: () => jsonResponse(200, {status: 'denied'}),
});
const session = await routes[DESKTOP_HANDOFF_CHANNELS.initiate](document.event, INSTANCE);
assert.deepEqual(await routes[DESKTOP_HANDOFF_CHANNELS.status](document.event, session.code), {status: 'denied'});
await assert.rejects(
() => routes[DESKTOP_HANDOFF_CHANNELS.status](document.event, session.code),
/not active for this renderer document/,
);
});
});
describe('the browser handoff preload bridge', () => {
test('reaches the two registered main-process channels and nothing else', async () => {
const {document, routes} = createHandoff({
+55 -5
View File
@@ -18,7 +18,11 @@ import type {
DesktopHandoffStatusResult,
DesktopHandoffUser,
} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {DESKTOP_HANDOFF_CHANNELS, DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {
DESKTOP_HANDOFF_CHANNELS,
DesktopHandoffReturnMethod,
DesktopHandoffStatus,
} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
const RENDERER_CONTEXT = 'Browser sign-in handoff';
const INITIATION_CONTEXT = 'Desktop handoff initiation';
@@ -32,6 +36,7 @@ const HANDOFF_TOKEN_MAX_LENGTH = 4096;
const HANDOFF_API_VERSION_MAX = 1000;
const HANDOFF_CODE_PATTERN = /^[A-Za-z0-9-]{1,64}$/u;
const HANDOFF_POLL_SECRET_PATTERN = /^[A-Za-z0-9_-]{1,256}$/u;
const HANDOFF_CODE_SEPARATOR_PATTERN = /[^A-Za-z0-9]/gu;
const TRAILING_SLASH_PATTERN = /\/+$/u;
interface BrowserHandoffLogger {
@@ -42,6 +47,7 @@ interface DesktopBrowserHandoffDependencies {
readonly logger: BrowserHandoffLogger;
readonly rendererDocumentOwners: RendererDocumentOwnerFactory;
readonly selectedInstanceClient: DesktopSelectedInstanceClient;
readonly returnUri: () => string | null;
}
interface ActiveHandoffSession {
@@ -53,6 +59,7 @@ interface ActiveHandoffSession {
readonly watcher: RendererDocumentOwnerWatcher;
inFlight: Promise<DesktopHandoffStatusResult> | null;
pollSecret: string | null;
grant: string | null;
}
interface HandoffJSONRequest {
@@ -144,6 +151,10 @@ function requireHandoffCode(value: unknown): string {
return value;
}
function normalizeHandoffCode(value: string): string {
return value.replace(HANDOFF_CODE_SEPARATOR_PATTERN, '').toUpperCase();
}
function readHandoffUser(value: unknown): DesktopHandoffUser | null {
if (!isRecord(value)) {
return null;
@@ -172,6 +183,9 @@ function readHandoffStatusResult(payload: Record<string, unknown> | null): Deskt
if (payload.status === DesktopHandoffStatus.EXPIRED) {
return {status: DesktopHandoffStatus.EXPIRED};
}
if (payload.status === DesktopHandoffStatus.DENIED) {
return {status: DesktopHandoffStatus.DENIED};
}
if (payload.status !== DesktopHandoffStatus.COMPLETED) {
return {status: DesktopHandoffStatus.PENDING};
}
@@ -198,6 +212,26 @@ export class DesktopBrowserHandoff {
});
}
public acceptReturnLink(url: URL): void {
const code = url.searchParams.get('code');
const grant = url.searchParams.get('grant');
if (code == null || !HANDOFF_CODE_PATTERN.test(code) || grant == null || !HANDOFF_POLL_SECRET_PATTERN.test(grant)) {
this.dependencies.logger.warn('[BrowserHandoff] Ignored a malformed sign-in return link');
return;
}
const normalizedCode = normalizeHandoffCode(code);
const session = [...this.sessions.values()].find(
(candidate) => normalizeHandoffCode(candidate.code) === normalizedCode,
);
if (session == null || session.pollSecret == null) {
this.dependencies.logger.warn(
'[BrowserHandoff] Ignored a sign-in return link for a request this app is not waiting on',
);
return;
}
session.grant = grant;
}
public cleanup(): void {
for (const frame of [...this.sessions.keys()]) {
this.releaseFrame(frame);
@@ -208,7 +242,14 @@ export class DesktopBrowserHandoff {
const owner = this.dependencies.rendererDocumentOwners.capture(event, RENDERER_CONTEXT);
const instance = requireDesktopHandoffInstance(request);
const origin = requireDesktopHTTPOrigin(new URL(instance.apiEndpoint).origin);
const response = await this.requestJSON({instance, origin, method: 'POST', path: HANDOFF_INITIATE_PATH});
const returnUri = this.dependencies.returnUri();
const response = await this.requestJSON({
...(returnUri == null ? {} : {body: {return_uri: returnUri}}),
instance,
origin,
method: 'POST',
path: HANDOFF_INITIATE_PATH,
});
if (response.status !== HttpStatus.OK && response.status !== HttpStatus.CREATED) {
throw new DesktopHandoffHTTPStatusError(INITIATION_CONTEXT, response.status);
}
@@ -223,9 +264,15 @@ export class DesktopBrowserHandoff {
}
const pollSecret = boundedString(response.payload.poll_secret, HANDOFF_FIELD_MAX_LENGTH);
const usablePollSecret = pollSecret != null && HANDOFF_POLL_SECRET_PATTERN.test(pollSecret) ? pollSecret : null;
const returnMethod =
returnUri != null &&
usablePollSecret != null &&
response.payload.return_method === DesktopHandoffReturnMethod.DEEP_LINK
? DesktopHandoffReturnMethod.DEEP_LINK
: DesktopHandoffReturnMethod.CODE;
owner.requireCurrent(RENDERER_CONTEXT);
this.rememberSession(owner, {code, expiresAtMs, instance, origin, pollSecret: usablePollSecret});
return {instance, code, expiresAt};
this.rememberSession(owner, {code, expiresAtMs, instance, origin, pollSecret: usablePollSecret, grant: null});
return {instance, code, expiresAt, returnMethod};
}
private async status(event: RendererDocumentIpcEvent, codeValue: unknown): Promise<DesktopHandoffStatusResult> {
@@ -261,7 +308,10 @@ export class DesktopBrowserHandoff {
let response: HandoffJSONResponse | null = null;
if (session.pollSecret != null) {
const attempt = await this.requestJSON({
body: {poll_secret: session.pollSecret},
body:
session.grant == null
? {poll_secret: session.pollSecret}
: {poll_secret: session.pollSecret, grant: session.grant},
instance: session.instance,
method: 'POST',
origin: session.origin,
+28 -1
View File
@@ -8,9 +8,11 @@ import {app, ipcMain} from 'electron';
import log from 'electron-log';
let initialDeepLink: string | null = null;
let handoffReturnLinkSink: ((url: URL) => void) | null = null;
const DUPLICATE_URL_SUPPRESS_MS = 1500;
const APP_PROTOCOL_SCHEME = `${APP_PROTOCOL}:`;
const HANDOFF_RETURN_HOST = 'handoff';
const DEEP_LINK_RENDERER_PAYLOAD_BLOCKLIST = /["'<>\\|\t\r\n]/;
const INSTANCE_DESIGNATING_KEYS = new Set([
'api',
@@ -91,6 +93,25 @@ function extractDeepLinkFromArgv(argv: ReadonlyArray<string>): string | null {
return argv.find(isAppProtocolUrl) ?? null;
}
export function setHandoffReturnLinkSink(sink: ((url: URL) => void) | null): void {
handoffReturnLinkSink = sink;
}
function parseHandoffReturnLink(rawUrl: string): URL | null {
try {
const parsed = new URL(rawUrl);
if (
parsed.protocol.toLowerCase() !== APP_PROTOCOL_SCHEME ||
parsed.hostname.toLowerCase() !== HANDOFF_RETURN_HOST
) {
return null;
}
return parsed;
} catch {
return null;
}
}
function normalizeDeepLinkForRenderer(rawUrl: string): string | null {
try {
const parsed = new URL(rawUrl);
@@ -135,7 +156,7 @@ function deliverDeepLinkHeldByTakeover(): void {
function registerInitialDeepLinkHandler(): void {
const deepLinkArg = extractDeepLinkFromArgv(process.argv);
if (deepLinkArg) {
if (deepLinkArg && parseHandoffReturnLink(deepLinkArg) == null) {
const normalized = normalizeDeepLinkForRenderer(deepLinkArg);
if (normalized) {
initialDeepLink = normalized;
@@ -151,6 +172,12 @@ function registerInitialDeepLinkHandler(): void {
}
function dispatchDeepLink(url: string): void {
const handoffReturnLink = parseHandoffReturnLink(url);
if (handoffReturnLink != null) {
handoffReturnLinkSink?.(handoffReturnLink);
showWindow();
return;
}
const normalized = normalizeDeepLinkForRenderer(url);
if (!normalized || shouldSuppressAsDuplicate(normalized)) return;
recordRecentDeepLink(url);
+8 -3
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DESKTOP_APP_ORIGIN, DESKTOP_PREBOOT_THEME_CHANNEL} from '@electron/common/Constants';
import {APP_PROTOCOL, DESKTOP_APP_ORIGIN, DESKTOP_PREBOOT_THEME_CHANNEL} from '@electron/common/Constants';
import {
type DesktopTroubleshootingSettings,
type DesktopWindowBehaviorSettings,
@@ -16,6 +16,7 @@ import type {
} from '@electron/common/Types';
import {DesktopBrowserHandoff} from '@electron/main/BrowserHandoff';
import {hasEnabledBlinkFeature, MIDDLE_CLICK_AUTOSCROLL_BLINK_FEATURE} from '@electron/main/ChromiumRuntime';
import {setHandoffReturnLinkSink} from '@electron/main/DeepLinks';
import {getDesktopAppStorage} from '@electron/main/DesktopAppStorage';
import {createDesktopAppStorageIpcRoutes} from '@electron/main/DesktopAppStorageIpc';
import {getLaunchDesktopTroubleshootingSettings} from '@electron/main/DesktopDebugInfo';
@@ -611,12 +612,15 @@ function registerBrowserHandoffHandlers(): void {
if (browserHandoff !== null) {
return;
}
browserHandoff = new DesktopBrowserHandoff({
const handoff = new DesktopBrowserHandoff({
logger: log,
rendererDocumentOwners: createPrivilegedRendererDocumentOwners('BrowserHandoff'),
selectedInstanceClient: getDesktopSelectedInstanceClient(),
returnUri: () => (app.isDefaultProtocolClient(APP_PROTOCOL) ? `${APP_PROTOCOL}://handoff` : null),
});
for (const [channel, handler] of Object.entries(browserHandoff.ipcRoutes())) {
browserHandoff = handoff;
setHandoffReturnLinkSink((url) => handoff.acceptReturnLink(url));
for (const [channel, handler] of Object.entries(handoff.ipcRoutes())) {
ipcMain.handle(channel, handler);
}
}
@@ -646,6 +650,7 @@ export function cleanupIpcHandlers(_options: {quitting?: boolean} = {}): void {
cleanupDesktopRuntimeConfigHandlers();
browserHandoff?.cleanup();
browserHandoff = null;
setHandoffReturnLinkSink(null);
if (linuxAppearanceSubscription) {
try {
linuxAppearanceSubscription.close();
@@ -199,6 +199,12 @@ describe('the main window only ever loads the local app document', () => {
}
});
test('a sign-in return link never reaches the renderer with its grant', async () => {
ipcHandlers.delete('get-initial-deep-link');
withArgv(['fluxer://handoff?code=ABCDEF-GHJKMN&grant=secret-grant'], () => initializeDeepLinks());
assert.equal(await ipcHandlers.get('get-initial-deep-link')(), null);
});
test('a deep link that names a site is not forwarded as one', async () => {
ipcHandlers.delete('get-initial-deep-link');
withArgv(['https://evil.example/channels/@me'], () => initializeDeepLinks());