using OAuth authorization code grant for all storage providers

This commit is contained in:
antelle 2020-04-17 19:42:35 +02:00
parent 5b6c707de5
commit c537d0f464
No known key found for this signature in database
GPG Key ID: 094A2F2D6136A4EE
18 changed files with 358 additions and 332 deletions

View File

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

View File

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

View File

@ -221,9 +221,6 @@ const Launcher = {
resolveProxy(url, callback) {
/* skip in cordova */
},
openWindow(opts) {
/* skip in cordova */
},
hideApp() {
/* skip in cordova */
},

View File

@ -205,9 +205,6 @@ const Launcher = {
callback(proxy);
});
},
openWindow(opts) {
return this.remoteApp().openWindow(opts);
},
hideApp() {
const app = this.remoteApp();
if (this.canMinimize()) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('&');
}
};

View File

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

View File

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

View File

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