mirror of https://github.com/keeweb/keeweb
using OAuth authorization code grant for all storage providers
parent
5b6c707de5
commit
c537d0f464
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -221,9 +221,6 @@ const Launcher = {
|
|||
resolveProxy(url, callback) {
|
||||
/* skip in cordova */
|
||||
},
|
||||
openWindow(opts) {
|
||||
/* skip in cordova */
|
||||
},
|
||||
hideApp() {
|
||||
/* skip in cordova */
|
||||
},
|
||||
|
|
|
@ -205,9 +205,6 @@ const Launcher = {
|
|||
callback(proxy);
|
||||
});
|
||||
},
|
||||
openWindow(opts) {
|
||||
return this.remoteApp().openWindow(opts);
|
||||
},
|
||||
hideApp() {
|
||||
const app = this.remoteApp();
|
||||
if (this.canMinimize()) {
|
||||
|
|
|
@ -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 };
|
|
@ -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)",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
|
@ -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({
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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('&');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue