From 998d5999a3b991fa02b36cf53bdf4911b1a68705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Buchowski?= Date: Thu, 9 Mar 2023 16:02:28 +0100 Subject: [PATCH] Cognito auth 1/2 - extracted electron logic (#5793) Provides functionality necessary for: - opening URLs in the system browser (so that we can handle OAuth flows outside of the app) - handling deep links to the application (so that the OAuth flows can return the user to the app) ### Important Notes - Modifies `preload.ts` to expose the ability to open the system browser to the sandboxed parts of the app. - Modifies `election-builder-config.ts` to register a deep link URL protocol scheme with the OS. - Modifies the client's `index.ts` to register a handler for Electron `open-url` events --- .../lib/client/electron-builder-config.ts | 36 ++- app/ide-desktop/lib/client/package.json | 1 + app/ide-desktop/lib/client/shared.ts | 16 ++ .../lib/client/src/authentication.ts | 214 ++++++++++++++++++ app/ide-desktop/lib/client/src/index.ts | 15 +- app/ide-desktop/lib/client/src/ipc.ts | 6 + app/ide-desktop/lib/client/src/preload.ts | 40 ++++ app/ide-desktop/package-lock.json | 18 ++ build-config.yaml | 2 +- 9 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 app/ide-desktop/lib/client/shared.ts create mode 100644 app/ide-desktop/lib/client/src/authentication.ts diff --git a/app/ide-desktop/lib/client/electron-builder-config.ts b/app/ide-desktop/lib/client/electron-builder-config.ts index 8f94c59c37..98c1ce01cf 100644 --- a/app/ide-desktop/lib/client/electron-builder-config.ts +++ b/app/ide-desktop/lib/client/electron-builder-config.ts @@ -1,13 +1,10 @@ -/** - * This module defines a TS script that is responsible for invoking the Electron Builder process to - * bundle the entire IDE distribution. +/** @file This module defines a TS script that is responsible for invoking the Electron Builder + * process to bundle the entire IDE distribution. * * There are two areas to this: * - Parsing CLI options as per our needs. - * - The default configuration of the build process. - * - * @module - */ + * - The default configuration of the build process. */ +/** @module */ import path from 'node:path' import child_process from 'node:child_process' @@ -18,6 +15,7 @@ import { notarize } from 'electron-notarize' import signArchivesMacOs from './tasks/signArchivesMacOs.js' import { project_manager_bundle } from './paths.js' +import * as shared from './shared.js' import build from '../../build.json' assert { type: 'json' } import yargs from 'yargs' import { MacOsTargetName } from 'app-builder-lib/out/options/macOptions' @@ -80,12 +78,34 @@ export const args: Arguments = await yargs(process.argv.slice(2)) export function createElectronBuilderConfig(args: Arguments): Configuration { return { appId: 'org.enso', - productName: 'Enso', + productName: shared.PRODUCT_NAME, extraMetadata: { version: build.version, }, copyright: 'Copyright © 2022 ${author}.', artifactName: 'enso-${os}-${version}.${ext}', + /** Definitions of URL {@link builder.Protocol} schemes used by the IDE. + * + * Electron will register all URL protocol schemes defined here with the OS. Once a URL protocol + * scheme is registered with the OS, any links using that scheme will function as "deep links". + * Deep links are used to redirect the user from external sources (e.g., system web browser, + * email client) to the IDE. + * + * Clicking a deep link will: + * - open the IDE (if it is not already open), + * - focus the IDE, and + * - navigate to the location specified by the URL of the deep link. + * + * For details on how this works, see: + * https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app */ + protocols: [ + /** Electron URL protocol scheme definition for deep links to authentication flow pages. */ + { + name: `${shared.PRODUCT_NAME} url`, + schemes: [shared.DEEP_LINK_SCHEME], + role: 'Editor', + }, + ], mac: { // We do not use compression as the build time is huge and file size saving is almost zero. target: (args.target as MacOsTargetName) ?? 'dmg', diff --git a/app/ide-desktop/lib/client/package.json b/app/ide-desktop/lib/client/package.json index 0ce48d065f..29f4cbdd1c 100644 --- a/app/ide-desktop/lib/client/package.json +++ b/app/ide-desktop/lib/client/package.json @@ -18,6 +18,7 @@ "main": "index.cjs", "dependencies": { "@types/mime-types": "^2.1.1", + "@types/opener": "^1.4.0", "chalk": "^5.2.0", "create-servers": "^3.2.0", "electron-is-dev": "^2.0.0", diff --git a/app/ide-desktop/lib/client/shared.ts b/app/ide-desktop/lib/client/shared.ts new file mode 100644 index 0000000000..d3c1e7a4a2 --- /dev/null +++ b/app/ide-desktop/lib/client/shared.ts @@ -0,0 +1,16 @@ +/** @file This module contains metadata about the product and distribution. + * For example, it contains: + * - the name of the product, and + * - custom URL protocol scheme definitions. + * + * This metadata is used in both the code building the client resources and the packaged code + * itself. */ + +/** Name of the product. */ +export const PRODUCT_NAME = 'Enso' + +/** URL protocol scheme for deep links to authentication flow pages. + * + * For example: the deep link URL + * `enso://authentication/register?code=...&state=...` uses this scheme. */ +export const DEEP_LINK_SCHEME = 'enso' diff --git a/app/ide-desktop/lib/client/src/authentication.ts b/app/ide-desktop/lib/client/src/authentication.ts new file mode 100644 index 0000000000..88666262eb --- /dev/null +++ b/app/ide-desktop/lib/client/src/authentication.ts @@ -0,0 +1,214 @@ +/** @file Definition of the Electron-specific parts of the authentication flows of the IDE. + * + * # Overview of Authentication/Authorization + * + * Actions like creating projects, opening projects, uploading files to the cloud, etc. require the + * user to be authenticated and authorized. Authenticated means that the user has an account that + * the application recognizes, and that the user has provided their credentials to prove that they + * are who they say they are. Authorized means that the user has the necessary permissions to + * perform the action. + * + * Authentication and authorization are provided by the user logging in with their credentials, + * which we exchange for a JSON Web Token (JWT). The JWT is sent with every HTTP request to the + * backend. + * + * The authentication module of the dashboard and IDE handles these flows: + * - registering a new user account, + * - signing in to an existing user account (in exchange for an access token), + * - signing out of the user account, + * - setting the user's username (i.e., display name used in place of their email address), + * - changing/resetting the user's password, + * - etc. + * + * # Electron Inter-Process Communication (IPC) + * + * If the user is signing in through a federated identity provider (e.g., Google or GitHub), the + * authentication flows need be able to to: + * - redirect the user from the IDE to external sources (e.g., system web browser), and + * - redirect the user from external sources to the IDE (e.g., system web browser, email client). + * + * The main Electron process can launch the system web browser. The dashboard and IDE are sandboxed, + * so they can not launch the system web browser. By registering Inter-Process Communication (IPC) + * listeners in the Electron app, we can bridge this gap, and allow the dashboad + IDE to emit + * events that signal to the main Electron process to open URLs in the system web browser. + * + * ## Redirect To System Web Browser + * + * The user must use the system browser to complete sensitive flows such as signup and signin. These + * flows should not be done in the app as the user cannot be expected to trust the app with their + * credentials. + * + * To redirect the user from the IDE to an external source: + * 1. Call the {@link initIpc} function to register a listener for + * {@link ipc.channel.openUrlInSystemBrowser} IPC events. + * 2. Emit an {@link ipc.channel.openUrlInSystemBrowser} event. The listener registered in the + * {@link initIpc} function will use the {@link opener} library to open the event's {@link URL} + * argument in the system web browser, in a cross-platform way. + * + * ## Redirect To IDE + * + * The user must be redirected back to the IDE from the system web browser after completing a + * sensitive flow such as signup or signin. The user may also be redirected to the IDE from an + * external source such as an email client after verifying their email address. + * + * To handle these redirects, we use deep links. Deep links are URLs that are used to redirect the + * user to a specific page in the application. To handle deep links, we use a custom URL protocol + * scheme. + * + * To prepare the application to handle deep links: + * - Register a custom URL protocol scheme with the OS (c.f., `electron-builder-config.ts`). + * - Define a listener for Electron {@link OPEN_URL_EVENT}s (c.f., {@link initOpenUrlListener}). + * - Define a listener for {@link ipc.channel.openDeepLink} events (c.f., `preload.ts`). + * + * Then when the user clicks on a deep link from an external source to the IDE: + * - The OS redirects the user to the application. + * - The application emits an Electron {@link OPEN_URL_EVENT}. + * - The {@link OPEN_URL_EVENT} listener checks if the {@link URL} is a deep link. + * - If the {@link URL} is a deep link, the {@link OPEN_URL_EVENT} listener prevents Electron from + * handling the event. + * - The {@link OPEN_URL_EVENT} listener then emits an {@link ipc.channel.openDeepLink} event. + * - The {@link ipc.channel.openDeepLink} listener registered by the dashboard receives the event. + * Then it parses the {@link URL} from the event's {@link URL} argument. Then it uses the + * {@link URL} to redirect the user to the dashboard, to the page specified in the {@link URL}'s + * `pathname`. */ + +import * as content from 'enso-content-config' +import * as electron from 'electron' +import * as ipc from 'ipc' +import * as shared from '../shared' +import * as childProcess from 'child_process' +import * as os from 'os' + +// ================= +// === Constants === +// ================= + +/** Name of the Electron event that is emitted when a URL is opened in Electron (e.g., when the user + * clicks a link in the dashboard). */ +const OPEN_URL_EVENT = 'open-url' + +// ======================================== +// === Initialize Authentication Module === +// ======================================== + +/** Configures all the functionality that must be set up in the Electron app to support + * authentication-related flows. Must be called in the Electron app `whenReady` event. + * + * @param window - A function that returns the main Electron window. This argument is a lambda and + * not a variable because the main window is not available when this function is called. This module + * does not use the `window` until after it is initialized, so while the lambda may return `null` in + * theory, it never will in practice. */ +export const initModule = (window: () => electron.BrowserWindow) => { + initIpc() + initOpenUrlListener(window) +} + +/** Registers an Inter-Process Communication (IPC) channel between the Electron application and the + * served website. + * + * This channel listens for {@link ipc.channel.openUrlInSystemBrowser} events. When this kind of + * event is fired, this listener will assume that the first and only argument of the event is a URL. + * This listener will then attempt to open the URL in a cross-platform way. The intent is to open + * the URL in the system browser. + * + * This functionality is necessary because we don't want to run the OAuth flow in the app. Users + * don't trust Electron apps to handle their credentials. */ +const initIpc = () => { + electron.ipcMain.on(ipc.channel.openUrlInSystemBrowser, (_event, url) => opener(url)) +} + +/** Registers a listener that fires a callback for `open-url` events, when the URL is a deep link. + * + * This listener is used to open a page in *this* application window, when the user is + * redirected to a URL with a protocol supported by this application. + * + * All URLs that aren't deep links (i.e., URLs that don't use the {@link shared.DEEP_LINK_SCHEME} + * protocol) will be ignored by this handler. Non-deep link URLs will be handled by Electron. */ +const initOpenUrlListener = (window: () => electron.BrowserWindow) => { + electron.app.on(OPEN_URL_EVENT, (event, url) => { + const parsedUrl = new URL(url) + /** Prevent Electron from handling the URL at all, because redirects can be dangerous. */ + event.preventDefault() + if (parsedUrl.protocol !== `${shared.DEEP_LINK_SCHEME}:`) { + content.logger.error(`${url} is not a deep link, ignoring.`) + return + } + window().webContents.send(ipc.channel.openDeepLink, url) + }) +} + +// ============== +// === Opener === +// ============== + +/** Function for opening URLs in the system browser in a cross-platform way. + * + * This function contains the contents of the `opener` NPM package, as of commit + * https://github.com/domenic/opener/commit/24edf48a38d1e23bbc5ffbeb079c206d5565f062. To avoid an + * extra external dependency, the contents of the function have been copied into this file. The + * function has been modified to follow the Enso style guide, and to be TypeScript-compatible. */ +const opener = (args: any, options?: any, callback?: any) => { + let platform = process.platform + + /** Attempt to detect Windows Subystem for Linux (WSL). WSL itself as Linux (which works in + * most cases), but in this specific case we need to treat it as actually being Windows. The + * "Windows-way" of opening things through cmd.exe works just fine here, whereas using xdg-open + * does not, since there is no X Windows in WSL. */ + if (platform === 'linux' && os.release().indexOf('Microsoft') !== -1) { + platform = 'win32' + } + + /** http://stackoverflow.com/q/1480971/3191, but see below for Windows. */ + let command + switch (platform) { + case 'win32': { + command = 'cmd.exe' + break + } + case 'darwin': { + command = 'open' + break + } + default: { + command = 'xdg-open' + break + } + } + + if (typeof args === 'string') { + args = [args] + } + + if (typeof options === 'function') { + callback = options + options = {} + } + + if (options && typeof options === 'object' && options.command) { + if (platform === 'win32') { + /* *always* use cmd on windows */ + args = [options.command].concat(args) + } else { + command = options.command + } + } + + if (platform === 'win32') { + /** On Windows, we really want to use the "start" command. But, the rules regarding + * arguments with spaces, and escaping them with quotes, can get really arcane. So the + * easiest way to deal with this is to pass off the responsibility to "cmd /c", which has + * that logic built in. + * + * Furthermore, if "cmd /c" double-quoted the first parameter, then "start" will interpret + * it as a window title, so we need to add a dummy empty-string window title: + * http://stackoverflow.com/a/154090/3191 + * + * Additionally, on Windows ampersand and caret need to be escaped when passed to "start" */ + args = args.map((value: any) => { + return value.replace(/[&^]/g, '^$&') + }) + args = ['/c', 'start', '""'].concat(args) + } + + return childProcess.execFile(command, args, options, callback) +} diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index 3c28f22fa0..1675fc56bb 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -5,6 +5,7 @@ * Inter-Process Communication channel, which enables seamless communication between the served web * application and the Electron process. */ +import * as authentication from 'authentication' import * as config from 'config' import * as configParser from 'config/parser' import * as content from 'enso-content-config' @@ -50,7 +51,13 @@ class App { this.setChromeOptions(chromeOptions) security.enableAll() electron.app.on('before-quit', () => (this.isQuitting = true)) - electron.app.whenReady().then(() => this.main(windowSize)) + /** TODO [NP]: https://github.com/enso-org/enso/issues/5851 + * The `electron.app.whenReady()` listener is preferable to the + * `electron.app.on('ready', ...)` listener. When the former is used in combination with + * the `authentication.initModule` call that is called in the listener, the application + * freezes. This freeze should be diagnosed and fixed. Then, the `whenReady()` listener + * should be used here instead. */ + electron.app.on('ready', () => this.main(windowSize)) this.registerShortcuts() } } @@ -102,6 +109,12 @@ class App { await this.startContentServerIfEnabled() await this.createWindowIfEnabled(windowSize) this.initIpc() + /** The non-null assertion on the following line is safe because the window + * initialization is guarded by the `createWindowIfEnabled` method. The window is + * not yet created at this point, but it will be created by the time the + * authentication module uses the lambda providing the window. */ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + authentication.initModule(() => this.window!) this.loadWindowContent() }) } catch (err) { diff --git a/app/ide-desktop/lib/client/src/ipc.ts b/app/ide-desktop/lib/client/src/ipc.ts index 3bbe87cdfc..c4935f4e33 100644 --- a/app/ide-desktop/lib/client/src/ipc.ts +++ b/app/ide-desktop/lib/client/src/ipc.ts @@ -12,4 +12,10 @@ export const channel = { profilesLoaded: 'profiles-loaded', saveProfile: 'save-profile', quit: 'quit-ide', + /** Channel for requesting that a URL be opened by the system browser. */ + openUrlInSystemBrowser: 'open-url-in-system-browser', + /** Channel for setting a callback that handles deep links to this application. */ + setDeepLinkHandler: 'set-deep-link-handler', + /** Channel for signaling that a deep link to this application was opened. */ + openDeepLink: 'open-deep-link', } diff --git a/app/ide-desktop/lib/client/src/preload.ts b/app/ide-desktop/lib/client/src/preload.ts index 0511975e9d..4bc6b0de57 100644 --- a/app/ide-desktop/lib/client/src/preload.ts +++ b/app/ide-desktop/lib/client/src/preload.ts @@ -6,6 +6,14 @@ const { contextBridge, ipcRenderer } = require('electron') import * as ipc from 'ipc' +// ================= +// === Constants === +// ================= + +/** Name given to the {@link AUTHENTICATION_API} object, when it is exposed on the Electron main + * window. */ +const AUTHENTICATION_API_KEY = 'authenticationApi' + // ====================== // === Profiling APIs === // ====================== @@ -50,3 +58,35 @@ contextBridge.exposeInMainWorld('enso_console', { // Print an error message with `console.error`. error: (data: any) => ipcRenderer.send('error', data), }) + +// ========================== +// === Authentication API === +// ========================== + +/** Object exposed on the Electron main window; provides proxy functions to: + * - open OAuth flows in the system browser, and + * - handle deep links from the system browser or email client to the dashboard. + * + * Some functions (i.e., the functions to open URLs in the system browser) are not available in + * sandboxed processes (i.e., the dashboard). So the {@link contextBridge.exposeInMainWorld} API is + * used to expose these functions. The functions are exposed via this "API object", which is added + * to the main window. + * + * For more details, see: https://www.electronjs.org/docs/latest/api/context-bridge#api-functions */ +const AUTHENTICATION_API = { + /** Opens a URL in the system browser (rather than in the app). + * + * OAuth URLs must be opened this way because the dashboard application is sandboxed and thus + * not privileged to do so unless we explicitly expose this functionality. */ + openUrlInSystemBrowser: (url: string) => + ipcRenderer.send(ipc.channel.openUrlInSystemBrowser, url), + /** Set the callback that will be called when a deep link to the application is opened. + * + * The callback is intended to handle links like + * `enso://authentication/register?code=...&state=...` from external sources like the user's + * system browser or email client. Handling the links involves resuming whatever flow was in + * progress when the link was opened (e.g., an OAuth registration flow). */ + setDeepLinkHandler: (callback: (url: string) => void) => + ipcRenderer.on(ipc.channel.openDeepLink, (_event, url) => callback(url)), +} +contextBridge.exposeInMainWorld(AUTHENTICATION_API_KEY, AUTHENTICATION_API) diff --git a/app/ide-desktop/package-lock.json b/app/ide-desktop/package-lock.json index c41133373a..6835601128 100644 --- a/app/ide-desktop/package-lock.json +++ b/app/ide-desktop/package-lock.json @@ -25,6 +25,7 @@ "version": "0.0.0-dev", "dependencies": { "@types/mime-types": "^2.1.1", + "@types/opener": "^1.4.0", "chalk": "^5.2.0", "create-servers": "^3.2.0", "electron-is-dev": "^2.0.0", @@ -1085,6 +1086,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, + "node_modules/@types/opener": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@types/opener/-/opener-1.4.0.tgz", + "integrity": "sha512-6KEABBcWAD6PychSyUUXuyCOIy64c5V6KJczTK4hpR5JKSBdK/s/LFQgRDnfy0F6lQWf9UvjE5Z2PbU7mhU3Tw==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/plist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.2.tgz", @@ -8968,6 +8977,14 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, + "@types/opener": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@types/opener/-/opener-1.4.0.tgz", + "integrity": "sha512-6KEABBcWAD6PychSyUUXuyCOIy64c5V6KJczTK4hpR5JKSBdK/s/LFQgRDnfy0F6lQWf9UvjE5Z2PbU7mhU3Tw==", + "requires": { + "@types/node": "*" + } + }, "@types/plist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.2.tgz", @@ -10395,6 +10412,7 @@ "version": "file:lib/client", "requires": { "@types/mime-types": "^2.1.1", + "@types/opener": "^1.4.0", "chalk": "^5.2.0", "create-servers": "^3.2.0", "crypto-js": "4.1.1", diff --git a/build-config.yaml b/build-config.yaml index 732137661a..6254b2effb 100644 --- a/build-config.yaml +++ b/build-config.yaml @@ -4,7 +4,7 @@ wasm-size-limit: 15.50 MiB required-versions: cargo-watch: ^8.1.1 - node: =18.12.1 + node: =18.14.1 wasm-pack: ^0.10.2 # TODO [mwu]: Script can install `flatc` later on (if `conda` is present), so this is not required. However it should # be required, if `conda` is missing.