mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 11:52:59 +03:00
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:
parent
6769ab0ee7
commit
998d5999a3
@ -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',
|
||||
|
@ -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",
|
||||
|
16
app/ide-desktop/lib/client/shared.ts
Normal file
16
app/ide-desktop/lib/client/shared.ts
Normal 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'
|
214
app/ide-desktop/lib/client/src/authentication.ts
Normal file
214
app/ide-desktop/lib/client/src/authentication.ts
Normal 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)
|
||||
}
|
@ -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) {
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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)
|
||||
|
18
app/ide-desktop/package-lock.json
generated
18
app/ide-desktop/package-lock.json
generated
@ -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",
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user