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
This commit is contained in:
Paweł Buchowski 2023-03-09 16:02:28 +01:00 committed by GitHub
parent 6769ab0ee7
commit 998d5999a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 338 additions and 10 deletions

View File

@ -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',

View File

@ -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",

View File

@ -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'

View File

@ -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)
}

View File

@ -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) {

View File

@ -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',
}

View File

@ -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)

View File

@ -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",

View File

@ -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.