mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(desktop): sign in with the browser without typing a code (#3286)
This commit is contained in:
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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user