From c537d0f464eac60825c3722717dad352eb7a7ee5 Mon Sep 17 00:00:00 2001 From: antelle Date: Fri, 17 Apr 2020 19:42:35 +0200 Subject: [PATCH] using OAuth authorization code grant for all storage providers --- app/scripts/comp/browser/auth-receiver.js | 2 +- app/scripts/comp/browser/popup-notifier.js | 99 +------- app/scripts/comp/launcher/launcher-cordova.js | 3 - .../comp/launcher/launcher-electron.js | 3 - app/scripts/const/cloud-storage-apps.js | 38 +++ app/scripts/locales/base.json | 2 + app/scripts/storage/impl/storage-dropbox.js | 62 ++++- app/scripts/storage/impl/storage-gdrive.js | 40 ++- app/scripts/storage/impl/storage-onedrive.js | 52 ++-- app/scripts/storage/index.js | 3 + app/scripts/storage/pkce.js | 32 +++ app/scripts/storage/storage-base.js | 234 +++++++++++------- app/scripts/storage/storage-oauth-listener.js | 96 +++---- app/scripts/util/features.js | 1 + app/scripts/util/formatting/url-format.js | 6 + desktop/app.js | 5 +- release-notes.md | 3 + test/src/util/formatting/url-format.js | 9 + 18 files changed, 358 insertions(+), 332 deletions(-) create mode 100644 app/scripts/const/cloud-storage-apps.js create mode 100644 app/scripts/storage/pkce.js diff --git a/app/scripts/comp/browser/auth-receiver.js b/app/scripts/comp/browser/auth-receiver.js index 6c4cf79c..17ea9d28 100644 --- a/app/scripts/comp/browser/auth-receiver.js +++ b/app/scripts/comp/browser/auth-receiver.js @@ -21,7 +21,7 @@ const AuthReceiver = { url.split(/[?#&]/g).forEach(part => { const parts = part.split('='); if (parts.length === 2) { - message[parts[0]] = parts[1]; + message[parts[0]] = decodeURIComponent(parts[1]); } }); return message; diff --git a/app/scripts/comp/browser/popup-notifier.js b/app/scripts/comp/browser/popup-notifier.js index 33ea10fe..fd504206 100644 --- a/app/scripts/comp/browser/popup-notifier.js +++ b/app/scripts/comp/browser/popup-notifier.js @@ -1,11 +1,10 @@ import { Events } from 'framework/events'; -import { AuthReceiver } from 'comp/browser/auth-receiver'; import { Launcher } from 'comp/launcher'; import { Alerts } from 'comp/ui/alerts'; -import { Links } from 'const/links'; import { Timeouts } from 'const/timeouts'; import { Locale } from 'util/locale'; import { Logger } from 'util/logger'; +import { noop } from 'util/fn'; const PopupNotifier = { logger: null, @@ -14,7 +13,7 @@ const PopupNotifier = { this.logger = new Logger('popup-notifier'); if (Launcher) { - window.open = this._openLauncherWindow.bind(this); + window.open = noop; } else { const windowOpen = window.open; window.open = function(...args) { @@ -35,100 +34,6 @@ const PopupNotifier = { } }, - _openLauncherWindow(url, title, settings) { - const opts = { show: false }; - if (settings) { - const settingsObj = {}; - settings.split(',').forEach(part => { - const parts = part.split('='); - settingsObj[parts[0].trim()] = parts[1].trim(); - }); - if (settingsObj.width) { - opts.width = +settingsObj.width; - } - if (settingsObj.height) { - opts.height = +settingsObj.height; - } - if (settingsObj.top) { - opts.y = +settingsObj.top; - } - if (settingsObj.left) { - opts.x = +settingsObj.left; - } - } - let win = Launcher.openWindow(opts); - win.webContents.on('will-redirect', (e, url) => { - if (PopupNotifier.isOwnUrl(url)) { - win.webContents.stop(); - win.close(); - PopupNotifier.processReturnToApp(url); - } - }); - win.webContents.on('will-navigate', (e, url) => { - if (PopupNotifier.isOwnUrl(url)) { - e.preventDefault(); - win.close(); - PopupNotifier.processReturnToApp(url); - } - }); - win.webContents.on('crashed', (e, killed) => { - this.logger.debug('crashed', e, killed); - this.deferCheckClosed(win); - win.close(); - win = null; - }); - win.webContents.on( - 'did-fail-load', - (e, errorCode, errorDescription, validatedUrl, isMainFrame) => { - this.logger.debug( - 'did-fail-load', - e, - errorCode, - errorDescription, - validatedUrl, - isMainFrame - ); - this.deferCheckClosed(win); - win.close(); - win = null; - } - ); - win.once('page-title-updated', () => { - setTimeout(() => { - if (win) { - win.show(); - win.focus(); - } - }, Timeouts.PopupWaitTime); - }); - win.on('closed', () => { - setTimeout( - PopupNotifier.triggerClosed.bind(PopupNotifier, win), - Timeouts.CheckWindowClosed - ); - win = null; - }); - win.loadURL(url); - Events.emit('popup-opened', win); - return win; - }, - - isOwnUrl(url) { - return ( - url.lastIndexOf(Links.WebApp, 0) === 0 || - url.lastIndexOf(location.origin + location.pathname, 0) === 0 - ); - }, - - processReturnToApp(url) { - const returnMessage = AuthReceiver.urlArgsToMessage(url); - if (Object.keys(returnMessage).length > 0) { - const evt = new Event('message'); - evt.data = returnMessage; - window.dispatchEvent(evt); - } - }, - deferCheckClosed(win) { setTimeout(PopupNotifier.checkClosed.bind(PopupNotifier, win), Timeouts.CheckWindowClosed); }, diff --git a/app/scripts/comp/launcher/launcher-cordova.js b/app/scripts/comp/launcher/launcher-cordova.js index 77b6b58e..4a8f10db 100644 --- a/app/scripts/comp/launcher/launcher-cordova.js +++ b/app/scripts/comp/launcher/launcher-cordova.js @@ -221,9 +221,6 @@ const Launcher = { resolveProxy(url, callback) { /* skip in cordova */ }, - openWindow(opts) { - /* skip in cordova */ - }, hideApp() { /* skip in cordova */ }, diff --git a/app/scripts/comp/launcher/launcher-electron.js b/app/scripts/comp/launcher/launcher-electron.js index ec844481..2d6f413c 100644 --- a/app/scripts/comp/launcher/launcher-electron.js +++ b/app/scripts/comp/launcher/launcher-electron.js @@ -205,9 +205,6 @@ const Launcher = { callback(proxy); }); }, - openWindow(opts) { - return this.remoteApp().openWindow(opts); - }, hideApp() { const app = this.remoteApp(); if (this.canMinimize()) { diff --git a/app/scripts/const/cloud-storage-apps.js b/app/scripts/const/cloud-storage-apps.js new file mode 100644 index 00000000..8c9f67ea --- /dev/null +++ b/app/scripts/const/cloud-storage-apps.js @@ -0,0 +1,38 @@ +// Secrets are not really secrets and are supposed to be embedded in the app code according to +// the Google's guide: https://developers.google.com/identity/protocols/oauth2#installed +// The process results in a client ID and, in some cases, a client secret, +// which you embed in the source code of your application. +// (In this context, the client secret is obviously not treated as a secret.) + +const DropboxApps = { + AppFolder: { id: 'qp7ctun6qt5n9d6', secret: '07s5r4ck1uvlj6a' }, + FullDropbox: { id: 'eor7hvv6u6oslq9', secret: 'ez04o1iwf6yprq3' } +}; + +const GDriveApps = { + Local: { + id: '783608538594-36tkdh8iscrq8t8dq87gghubnhivhjp5.apps.googleusercontent.com', + secret: 'yAtyfc9TIQ9GyQgQmo3i0HAP' + }, + Production: { + id: '847548101761-koqkji474gp3i2gn3k5omipbfju7pbt1.apps.googleusercontent.com', + secret: '42HeSBybXDZjvweotq4o4CkJ' + }, + Desktop: { + id: '847548101761-h2pcl2p6m1tssnlqm0vrm33crlveccbr.apps.googleusercontent.com', + secret: 'nTSCiqXtUNmURIIdASaC1TJK' + } +}; + +const OneDriveApps = { + Local: { + id: 'b97c53d5-db5b-4124-aab9-d39195293815', + secret: 'V9b6:iJU]N7cImE1f_OLNjqZJDBnumR?' + }, + Production: { + id: 'bbc74d1b-3a9c-46e6-9da4-4c645e830923', + secret: 'aOMJaktJEAs_Tmh]fx4iQ[Zd3mp3KK7-' + } +}; + +export { DropboxApps, GDriveApps, OneDriveApps }; diff --git a/app/scripts/locales/base.json b/app/scripts/locales/base.json index 62eb83e6..04646479 100644 --- a/app/scripts/locales/base.json +++ b/app/scripts/locales/base.json @@ -597,6 +597,8 @@ "dropboxSetupDesc": "Some configuration is required to use Dropbox in a self-hosted app. Please create your own Dropbox app and fill in its key below.", "dropboxAppKey": "Dropbox app key", "dropboxAppKeyDesc": "Copy the key from your Dropbox app (Developer settings)", + "dropboxAppSecret": "Dropbox app secret", + "dropboxAppSecretDesc": "The secret can be found next to the app key", "dropboxFolder": "App folder", "dropboxFolderDesc": "If your app is linked to entire Dropbox (not app folder), set the folder with your kdbx files here", "dropboxFolderSettingsDesc": "Select any folder in your Dropbox where files will be stored (root folder by default)", diff --git a/app/scripts/storage/impl/storage-dropbox.js b/app/scripts/storage/impl/storage-dropbox.js index 81c7e882..cc33c928 100644 --- a/app/scripts/storage/impl/storage-dropbox.js +++ b/app/scripts/storage/impl/storage-dropbox.js @@ -1,16 +1,14 @@ import { StorageBase } from 'storage/storage-base'; import { Features } from 'util/features'; import { UrlFormat } from 'util/formatting/url-format'; - -const DropboxKeys = { - AppFolder: 'qp7ctun6qt5n9d6', - FullDropbox: 'eor7hvv6u6oslq9' -}; +import { DropboxApps } from 'const/cloud-storage-apps'; const DropboxCustomErrors = { BadKey: 'bad-key' }; +// https://www.dropbox.com/developers/documentation/http/documentation#oauth2-authorize + class StorageDropbox extends StorageBase { name = 'dropbox'; icon = 'dropbox'; @@ -49,12 +47,23 @@ class StorageDropbox extends StorageBase { } _getKey() { - return this.appSettings.dropboxAppKey || DropboxKeys.AppFolder; + return this.appSettings.dropboxAppKey || DropboxApps.AppFolder.id; + } + + _getSecret() { + const key = this._getKey(); + if (key === DropboxApps.AppFolder.id) { + return DropboxApps.AppFolder.secret; + } + if (key === DropboxApps.FullDropbox.id) { + return DropboxApps.FullDropbox.secret; + } + return this.appSettings.dropboxSecret; } _isValidKey() { const key = this._getKey(); - const isBuiltIn = key === DropboxKeys.AppFolder || key === DropboxKeys.FullDropbox; + const isBuiltIn = key === DropboxApps.AppFolder.id || key === DropboxApps.FullDropbox.id; return key && key.indexOf(' ') < 0 && (!isBuiltIn || this._canUseBuiltInKeys()); } @@ -66,14 +75,17 @@ class StorageDropbox extends StorageBase { return { scope: '', url: 'https://www.dropbox.com/oauth2/authorize', + tokenUrl: 'https://api.dropboxapi.com/oauth2/token', clientId: this._getKey(), + clientSecret: this._getSecret(), + pkce: false, width: 600, height: 400 }; } needShowOpenConfig() { - return !this._isValidKey(); + return !this._isValidKey() || !this._getSecret(); } getOpenConfig() { @@ -88,6 +100,14 @@ class StorageDropbox extends StorageBase { required: true, pattern: '\\w+' }, + { + id: 'secret', + title: 'dropboxAppSecret', + desc: 'dropboxAppSecretDesc', + type: 'text', + required: true, + pattern: '\\w+' + }, { id: 'folder', title: 'dropboxFolder', @@ -118,6 +138,15 @@ class StorageDropbox extends StorageBase { pattern: '\\w+', value: appKey }; + const secretField = { + id: 'secret', + title: 'dropboxAppSecret', + desc: 'dropboxAppSecretDesc', + type: 'text', + required: true, + pattern: '\\w+', + value: this.appSettings.dropboxSecret || '' + }; const folderField = { id: 'folder', title: 'dropboxFolder', @@ -128,24 +157,26 @@ class StorageDropbox extends StorageBase { const canUseBuiltInKeys = this._canUseBuiltInKeys(); if (canUseBuiltInKeys) { fields.push(linkField); - if (appKey === DropboxKeys.AppFolder) { + if (appKey === DropboxApps.AppFolder.id) { linkField.value = 'app'; - } else if (appKey === DropboxKeys.FullDropbox) { + } else if (appKey === DropboxApps.FullDropbox.id) { linkField.value = 'full'; fields.push(folderField); } else { fields.push(keyField); + fields.push(secretField); fields.push(folderField); } } else { fields.push(keyField); + fields.push(secretField); fields.push(folderField); } return { fields }; } applyConfig(config, callback) { - if (config.key === DropboxKeys.AppFolder || config.key === DropboxKeys.FullDropbox) { + if (config.key === DropboxApps.AppFolder.id || config.key === DropboxApps.FullDropbox.id) { return callback(DropboxCustomErrors.BadKey); } // TODO: try to connect using new key @@ -154,6 +185,7 @@ class StorageDropbox extends StorageBase { } this.appSettings.set({ dropboxAppKey: config.key, + dropboxSecret: config.secret, dropboxFolder: config.folder }); callback(); @@ -165,10 +197,10 @@ class StorageDropbox extends StorageBase { key = 'dropboxAppKey'; switch (value) { case 'app': - value = DropboxKeys.AppFolder; + value = DropboxApps.AppFolder.id; break; case 'full': - value = DropboxKeys.FullDropbox; + value = DropboxApps.FullDropbox.id; break; case 'custom': value = '(your app key)'; @@ -182,6 +214,10 @@ class StorageDropbox extends StorageBase { key = 'dropboxAppKey'; this._oauthRevokeToken(); break; + case 'secret': + key = 'dropboxSecret'; + this._oauthRevokeToken(); + break; case 'folder': key = 'dropboxFolder'; value = this._fixConfigFolder(value); diff --git a/app/scripts/storage/impl/storage-gdrive.js b/app/scripts/storage/impl/storage-gdrive.js index 13982a50..ae2be5f2 100644 --- a/app/scripts/storage/impl/storage-gdrive.js +++ b/app/scripts/storage/impl/storage-gdrive.js @@ -1,22 +1,12 @@ import { StorageBase } from 'storage/storage-base'; import { Locale } from 'util/locale'; import { Features } from 'util/features'; +import { GDriveApps } from 'const/cloud-storage-apps'; -const GDriveClientId = { - Local: '783608538594-36tkdh8iscrq8t8dq87gghubnhivhjp5.apps.googleusercontent.com', - Production: '847548101761-koqkji474gp3i2gn3k5omipbfju7pbt1.apps.googleusercontent.com', - Desktop: '847548101761-h2pcl2p6m1tssnlqm0vrm33crlveccbr.apps.googleusercontent.com' -}; -const GDriveClientSecret = { - // They are not really secrets and are supposed to be embedded in the app code according to - // the official guide: https://developers.google.com/identity/protocols/oauth2#installed - // The process results in a client ID and, in some cases, a client secret, - // which you embed in the source code of your application. - // (In this context, the client secret is obviously not treated as a secret.) - Desktop: 'nTSCiqXtUNmURIIdASaC1TJK' -}; const NewFileIdPrefix = 'NewFile:'; +// https://developers.google.com/identity/protocols/oauth2/web-server + class StorageGDrive extends StorageBase { name = 'gdrive'; enabled = true; @@ -248,16 +238,14 @@ class StorageGDrive extends StorageBase { _getOAuthConfig() { let clientId = this.appSettings.gdriveClientId; - let clientSecret; - if (!clientId) { + let clientSecret = this.appSettings.gdriveClientSecret; + if (!clientId || !clientSecret) { if (Features.isDesktop) { - clientId = GDriveClientId.Desktop; - clientSecret = GDriveClientSecret.Desktop; + ({ id: clientId, secret: clientSecret } = GDriveApps.Desktop); + } else if (Features.isLocal) { + ({ id: clientId, secret: clientSecret } = GDriveApps.Local); } else { - clientId = - location.origin.indexOf('localhost') >= 0 - ? GDriveClientId.Local - : GDriveClientId.Production; + ({ id: clientId, secret: clientSecret } = GDriveApps.Production); } } return { @@ -267,13 +255,13 @@ class StorageGDrive extends StorageBase { clientId, clientSecret, width: 600, - height: 400 + height: 400, + pkce: true, + redirectUrlParams: { + 'access_type': 'offline' + } }; } - - _useLocalOAuthRedirectListener() { - return Features.isDesktop; - } } export { StorageGDrive }; diff --git a/app/scripts/storage/impl/storage-onedrive.js b/app/scripts/storage/impl/storage-onedrive.js index a6be08e9..7862041d 100644 --- a/app/scripts/storage/impl/storage-onedrive.js +++ b/app/scripts/storage/impl/storage-onedrive.js @@ -1,10 +1,8 @@ import { StorageBase } from 'storage/storage-base'; -import { noop } from 'util/fn'; +import { OneDriveApps } from 'const/cloud-storage-apps'; +import { Features } from 'util/features'; -const OneDriveClientId = { - Production: '000000004818ED3A', - Local: '0000000044183D18' -}; +// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow class StorageOneDrive extends StorageBase { name = 'onedrive'; @@ -227,45 +225,27 @@ class StorageOneDrive extends StorageBase { super.setEnabled(enabled); } - _getClientId() { - let clientId = this.appSettings.onedriveClientId; - if (!clientId) { - clientId = - location.origin.indexOf('localhost') >= 0 - ? OneDriveClientId.Local - : OneDriveClientId.Production; - } - return clientId; - } - _getOAuthConfig() { - const clientId = this._getClientId(); + let clientId = this.appSettings.onedriveClientId; + let clientSecret = this.appSettings.onedriveClientSecret; + if (!clientId || !clientSecret) { + if (Features.isLocal) { + ({ id: clientId, secret: clientSecret } = OneDriveApps.Local); + } else { + ({ id: clientId, secret: clientSecret } = OneDriveApps.Production); + } + } return { url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - scope: 'files.readwrite', + tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + scope: 'files.readwrite offline_access', clientId, + clientSecret, + pkce: true, width: 600, height: 500 }; } - - _popupOpened(popupWindow) { - if (popupWindow.webContents) { - popupWindow.webContents.on('did-finish-load', e => { - const webContents = e.sender.webContents; - const url = webContents.getURL(); - if ( - url && - url.startsWith('https://login.microsoftonline.com/common/oauth2/v2.0/authorize') - ) { - // click the login button mentioned in #821 - const script = `const selector = '[role="button"][aria-describedby="tileError loginHeader"]'; -if (document.querySelectorAll(selector).length === 1) document.querySelector(selector).click()`; - webContents.executeJavaScript(script).catch(noop); - } - }); - } - } } export { StorageOneDrive }; diff --git a/app/scripts/storage/index.js b/app/scripts/storage/index.js index 396ec9cd..3b323174 100644 --- a/app/scripts/storage/index.js +++ b/app/scripts/storage/index.js @@ -6,6 +6,7 @@ import { StorageFileCache } from 'storage/impl/storage-file-cache'; import { StorageGDrive } from 'storage/impl/storage-gdrive'; import { StorageOneDrive } from 'storage/impl/storage-onedrive'; import { StorageWebDav } from 'storage/impl/storage-webdav'; +import { createOAuthSession } from 'storage/pkce'; const BuiltInStorage = { file: new StorageFile(), @@ -24,4 +25,6 @@ if (!Launcher || Launcher.thirdPartyStoragesSupported) { Object.assign(Storage, ThirdPartyStorage); } +requestAnimationFrame(createOAuthSession); + export { Storage }; diff --git a/app/scripts/storage/pkce.js b/app/scripts/storage/pkce.js new file mode 100644 index 00000000..566661a9 --- /dev/null +++ b/app/scripts/storage/pkce.js @@ -0,0 +1,32 @@ +import kdbxweb from 'kdbxweb'; + +let newOAuthSession; + +function createOAuthSession() { + const session = newOAuthSession; + + const state = kdbxweb.ByteUtils.bytesToHex(kdbxweb.Random.getBytes(64)); + const codeVerifier = kdbxweb.ByteUtils.bytesToHex(kdbxweb.Random.getBytes(50)); + + const codeVerifierBytes = kdbxweb.ByteUtils.arrayToBuffer( + kdbxweb.ByteUtils.stringToBytes(codeVerifier) + ); + kdbxweb.CryptoEngine.sha256(codeVerifierBytes).then(hash => { + const codeChallenge = kdbxweb.ByteUtils.bytesToBase64(hash) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + newOAuthSession = { + state, + codeChallenge, + codeVerifier + }; + }); + + newOAuthSession = null; + + return session; +} + +export { createOAuthSession }; diff --git a/app/scripts/storage/storage-base.js b/app/scripts/storage/storage-base.js index 8203d5eb..f9ba6c16 100644 --- a/app/scripts/storage/storage-base.js +++ b/app/scripts/storage/storage-base.js @@ -8,6 +8,8 @@ import { UrlFormat } from 'util/formatting/url-format'; import { Launcher } from 'comp/launcher'; import { omitEmpty } from 'util/fn'; import { Timeouts } from 'const/timeouts'; +import { Features } from 'util/features'; +import { createOAuthSession } from 'storage/pkce'; const MaxRequestRetries = 3; @@ -88,8 +90,8 @@ class StorageBase { } _httpRequest(config, onLoad) { - const httpRequest = Launcher ? this._httpRequestLauncher : this._httpRequestWeb; - httpRequest(config, onLoad); + const httpRequest = Features.isDesktop ? this._httpRequestLauncher : this._httpRequestWeb; + httpRequest.call(this, config, onLoad); } _httpRequestWeb(config, onLoad) { @@ -127,49 +129,81 @@ class StorageBase { } _httpRequestLauncher(config, onLoad) { - const https = Launcher.req('https'); - const req = https.request(config.url, { - method: config.method || 'GET', - headers: config.headers, - timeout: Timeouts.DefaultHttpRequest - }); - req.on('response', res => { - const chunks = []; - res.on('data', chunk => chunks.push(chunk)); - res.on('end', () => { - let response = Buffer.concat(chunks); - if (config.responseType === 'json') { - response = JSON.parse(response.toString('utf8')); + Launcher.resolveProxy(config.url, proxy => { + const https = Launcher.req('https'); + + const opts = Launcher.req('url').parse(config.url); + + opts.method = config.method || 'GET'; + opts.headers = { + 'User-Agent': navigator.userAgent, + ...config.headers + }; + opts.timeout = Timeouts.DefaultHttpRequest; + + let data; + if (config.data) { + if (config.dataIsMultipart) { + data = Buffer.concat(config.data.map(chunk => Buffer.from(chunk))); } else { - response = response.buffer.slice( - response.byteOffset, - response.byteOffset + response.length - ); + data = Buffer.from(config.data); } - onLoad({ - status: res.statusCode, - response, - getResponseHeader: name => res.headers[name.toLowerCase()] + opts.headers['Content-Length'] = data.byteLength; + } + + if (proxy) { + opts.headers.Host = opts.host; + opts.host = proxy.host; + opts.port = proxy.port; + opts.path = config.url; + } + + const req = https.request(opts); + + req.on('response', res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => { + this.logger.debug( + 'HTTP response', + opts.method, + config.url, + res.statusCode, + res.headers + ); + + let response = Buffer.concat(chunks); + if (config.responseType === 'json') { + try { + response = JSON.parse(response.toString('utf8')); + } catch (e) { + return config.error && config.error('json parse error'); + } + } else { + response = response.buffer.slice( + response.byteOffset, + response.byteOffset + response.length + ); + } + onLoad({ + status: res.statusCode, + response, + getResponseHeader: name => res.headers[name.toLowerCase()] + }); }); }); - }); - req.on('error', () => { - return config.error && config.error('network error', {}); - }); - req.on('timeout', () => { - req.abort(); - return config.error && config.error('timeout', {}); - }); - if (config.data) { - let data; - if (config.dataIsMultipart) { - data = Buffer.concat(config.data.map(chunk => Buffer.from(chunk))); - } else { - data = Buffer.from(config.data); + req.on('error', () => { + return config.error && config.error('network error', {}); + }); + req.on('timeout', () => { + req.abort(); + return config.error && config.error('timeout', {}); + }); + if (data) { + req.write(data); } - req.write(data); - } - req.end(); + req.end(); + }); } _openPopup(url, title, width, height, extras) { @@ -231,58 +265,64 @@ class StorageBase { return this._oauthExchangeRefreshToken(callback); } - if (this._useLocalOAuthRedirectListener()) { - return StorageOAuthListener.listen() - .then(listener => { - const url = UrlFormat.makeUrl(opts.url, { - 'client_id': opts.clientId, - 'scope': opts.scope, - 'state': listener.state, - 'redirect_uri': listener.redirectUri, - 'response_type': 'code', - 'code_challenge': listener.codeChallenge, - 'code_challenge_method': 'S256' - }); - Launcher.openLink(url); - callback('browser-auth-started'); - listener.callback = code => this._oauthCodeReceived(code, listener); - }) - .catch(err => callback(err)); + const session = createOAuthSession(); + + let listener; + if (Features.isDesktop) { + listener = StorageOAuthListener.listen(); + session.redirectUri = listener.redirectUri; + } else { + session.redirectUri = this._getOauthRedirectUrl(); } + const pkceParams = opts.pkce + ? { + 'code_challenge': session.codeChallenge, + 'code_challenge_method': 'S256' + } + : undefined; + const url = UrlFormat.makeUrl(opts.url, { 'client_id': opts.clientId, 'scope': opts.scope, - 'response_type': 'token', - 'redirect_uri': this._getOauthRedirectUrl() + 'state': session.state, + 'redirect_uri': session.redirectUri, + 'response_type': 'code', + ...pkceParams }); - this.logger.debug('OAuth: popup opened'); + if (listener) { + listener.on('ready', () => { + Launcher.openLink(url); + callback('browser-auth-started'); + }); + listener.on('error', err => callback(err)); + listener.on('result', result => this._oauthCodeReceived(result, session)); + return; + } + const popupWindow = this._openPopup(url, 'OAuth', opts.width, opts.height); if (!popupWindow) { return callback('OAuth: cannot open popup'); } - this._popupOpened(popupWindow); + + this.logger.debug('OAuth: popup opened'); + const popupClosed = () => { Events.off('popup-closed', popupClosed); window.removeEventListener('message', windowMessage); this.logger.error('OAuth error', 'popup closed'); callback('OAuth: popup closed'); }; + const windowMessage = e => { - if (!e.data) { - return; - } - const token = this._oauthProcessReturn(e.data); - if (token) { + if (e.data && e.data.error) { + this.logger.error('OAuth error', e.data.error, e.data.error_description); + callback('OAuth: ' + e.data.error); + } else if (e.data && e.data.code) { Events.off('popup-closed', popupClosed); window.removeEventListener('message', windowMessage); - if (token.error) { - this.logger.error('OAuth error', token.error, token.errorDescription); - callback('OAuth: ' + token.error); - } else { - callback(); - } + this._oauthCodeReceived(e.data, session, callback); } else { this.logger.debug('Skipped OAuth message', e.data); } @@ -291,8 +331,6 @@ class StorageBase { window.addEventListener('message', windowMessage); } - _popupOpened(popupWindow) {} - _oauthProcessReturn(message) { const token = this._oauthMsgToToken(message); if (token && !token.error) { @@ -357,34 +395,54 @@ class StorageBase { return true; } - _useLocalOAuthRedirectListener() { - return false; - } + _oauthCodeReceived(result, session, callback) { + if (!result.state) { + this.logger.info('OAuth result has no state'); + return callback && callback('OAuth result has no state'); + } + if (result.state !== session.state) { + this.logger.info('OAuth result has bad state'); + return callback && callback('OAuth result has bad state'); + } + + if (!result.code) { + this.logger.info('OAuth result has no code'); + return callback && callback('OAuth result has no code'); + } - _oauthCodeReceived(code, listener) { this.logger.debug('OAuth code received'); - Launcher.showMainWindow(); + + if (Features.isDesktop) { + Launcher.showMainWindow(); + } const config = this._getOAuthConfig(); + const pkceParams = config.pkce ? { 'code_verifier': session.codeVerifier } : undefined; + this._xhr({ url: config.tokenUrl, method: 'POST', responseType: 'json', skipAuth: true, - data: JSON.stringify({ + data: UrlFormat.buildFormData({ 'client_id': config.clientId, 'client_secret': config.clientSecret, 'grant_type': 'authorization_code', - code, - 'code_verifier': listener.codeVerifier, - 'redirect_uri': listener.redirectUri + 'code': result.code, + 'redirect_uri': session.redirectUri, + ...pkceParams }), - dataType: 'application/json', + dataType: 'application/x-www-form-urlencoded', success: response => { - this.logger.debug('OAuth code exchanged'); - this._oauthProcessReturn(response); + this.logger.debug('OAuth code exchanged', response); + const token = this._oauthProcessReturn(response); + if (token && token.error) { + return callback && callback('OAuth code exchange error: ' + token.error); + } + callback && callback(); }, error: err => { this.logger.error('Error exchanging OAuth code', err); + callback && callback('OAuth code exchange error: ' + err); } }); } @@ -398,13 +456,13 @@ class StorageBase { method: 'POST', responseType: 'json', skipAuth: true, - data: JSON.stringify({ + data: UrlFormat.buildFormData({ 'client_id': config.clientId, 'client_secret': config.clientSecret, 'grant_type': 'refresh_token', 'refresh_token': refreshToken }), - dataType: 'application/json', + dataType: 'application/x-www-form-urlencoded', success: response => { this.logger.debug('Refresh token exchanged'); this._oauthProcessReturn({ diff --git a/app/scripts/storage/storage-oauth-listener.js b/app/scripts/storage/storage-oauth-listener.js index 623af7ba..83a8a3dc 100644 --- a/app/scripts/storage/storage-oauth-listener.js +++ b/app/scripts/storage/storage-oauth-listener.js @@ -1,6 +1,6 @@ +import EventEmitter from 'events'; import { Logger } from 'util/logger'; import { Launcher } from 'comp/launcher'; -import { noop } from 'util/fn'; import { Locale } from 'util/locale'; const DefaultPort = 48149; @@ -10,40 +10,41 @@ const StorageOAuthListener = { server: null, listen() { - return new Promise((resolve, reject) => { - if (this.server) { - this.stop(); - } + if (this.server) { + this.stop(); + } - const listener = { - callback: noop, - state: Math.round(Math.random() * Date.now()).toString() - }; - - const http = Launcher.req('http'); - const server = http.createServer((req, resp) => { - resp.writeHead(200, 'OK', { - 'Content-Type': 'text/plain; charset=UTF-8' - }); - resp.end(Locale.appBrowserAuthComplete); - this.handleResult(req.url, listener); - }); - - const port = DefaultPort; - logger.info(`Starting OAuth listener on port ${port}...`); - server.listen(port); - server.on('error', err => { - logger.error('Failed to start OAuth listener', err); - reject('Failed to start OAuth listener: ' + err); - server.close(); - }); - server.on('listening', () => { - this.server = server; - listener.redirectUri = `http://127.0.0.1:${port}/oauth-result`; - this._setCodeVerifier(listener); - resolve(listener); - }); + const listener = {}; + Object.keys(EventEmitter.prototype).forEach(key => { + listener[key] = EventEmitter.prototype[key]; }); + + const http = Launcher.req('http'); + const server = http.createServer((req, resp) => { + resp.writeHead(200, 'OK', { + 'Content-Type': 'text/plain; charset=UTF-8' + }); + resp.end(Locale.appBrowserAuthComplete); + this.handleResult(req.url, listener); + }); + + const port = DefaultPort; + + logger.info(`Starting OAuth listener on port ${port}...`); + server.listen(port); + + server.on('error', err => { + logger.error('Failed to start OAuth listener', err); + listener.emit('error', 'Failed to start OAuth listener: ' + err); + server.close(); + }); + server.on('listening', () => { + this.server = server; + listener.emit('ready'); + }); + + listener.redirectUri = `http://localhost:${port}/oauth-result`; + return listener; }, stop() { @@ -58,35 +59,8 @@ const StorageOAuthListener = { this.stop(); url = new URL(url, 'http://localhost'); const state = url.searchParams.get('state'); - if (!state) { - logger.info('OAuth result has no state'); - return; - } - if (state !== listener.state) { - logger.info('OAuth result has bad state'); - return; - } const code = url.searchParams.get('code'); - if (!code) { - logger.info('OAuth result has no code'); - return; - } - listener.callback(code); - }, - - _setCodeVerifier(listener) { - const crypto = Launcher.req('crypto'); - - listener.codeVerifier = crypto.randomBytes(50).toString('hex'); - - const hash = crypto.createHash('sha256'); - hash.update(listener.codeVerifier); - - listener.codeChallenge = hash - .digest('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); + listener.emit('result', { state, code }); } }; diff --git a/app/scripts/util/features.js b/app/scripts/util/features.js index 597fe23c..66d0574a 100644 --- a/app/scripts/util/features.js +++ b/app/scripts/util/features.js @@ -15,6 +15,7 @@ const Features = { isSelfHosted: !isDesktop && !/^http(s?):\/\/((localhost:8085)|((app|beta)\.keeweb\.info))/.test(location.href), + isLocal: location.origin.indexOf('localhost') >= 0, needFixClicks: /Edge\/14/.test(navigator.appVersion), canUseWasmInWebWorker: !isDesktop && !/Chrome/.test(navigator.appVersion), diff --git a/app/scripts/util/formatting/url-format.js b/app/scripts/util/formatting/url-format.js index d4167786..11ae8f3c 100644 --- a/app/scripts/util/formatting/url-format.js +++ b/app/scripts/util/formatting/url-format.js @@ -29,6 +29,12 @@ const UrlFormat = { .map(([key, value]) => key + '=' + encodeURIComponent(value)) .join('&'); return base + '?' + queryString; + }, + + buildFormData(params) { + return Object.entries(params) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join('&'); } }; diff --git a/desktop/app.js b/desktop/app.js index 39afd68a..d021e2bd 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -111,9 +111,6 @@ app.restartApp = function() { restartPending = false; }, 1000); }; -app.openWindow = function(opts) { - return new electron.BrowserWindow(opts); -}; app.minimizeApp = function(menuItemLabels) { let imagePath; mainWindow.hide(); @@ -237,7 +234,7 @@ function createMainWindow() { emitRemoteEvent('os-lock'); }); mainWindow.webContents.on('will-navigate', (e, url) => { - if (!url.startsWith('https://beta.keeweb.info/')) { + if (!url.startsWith('https://beta.keeweb.info/') && !url.startsWith(htmlPath)) { emitRemoteEvent('log', { message: `Prevented navigation: ${url}` }); e.preventDefault(); } diff --git a/release-notes.md b/release-notes.md index f062f846..afdd9a62 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,5 +1,8 @@ Release notes ------------- +##### v1.14.0 (TBD) +`+` using OAuth authorization code grant for all storage providers + ##### v1.13.4 (2020-04-15) `-` fix #1457: fixed styles in theme plugins `+` #1456: options to hide webdav and password generator diff --git a/test/src/util/formatting/url-format.js b/test/src/util/formatting/url-format.js index 698c6d60..76bb8272 100644 --- a/test/src/util/formatting/url-format.js +++ b/test/src/util/formatting/url-format.js @@ -32,4 +32,13 @@ describe('UrlFormat', () => { }) ).to.eql('/path?hello=world&data=%3D%20%26'); }); + + it('should make form-data params', () => { + expect( + UrlFormat.buildFormData({ + hello: 'world', + data: '= &' + }) + ).to.eql('hello=world&data=%3D%20%26'); + }); });