From 4f273eca0381400d863cfc15d16e103afb99d958 Mon Sep 17 00:00:00 2001 From: Danny Coates Date: Fri, 24 Jul 2020 18:11:50 -0700 Subject: [PATCH] added oauth refresh token support Co-authored-by: timvisee --- app/controller.js | 10 +++- app/ui/account.js | 7 ++- app/ui/signupDialog.js | 18 +++--- app/user.js | 85 ++++++++++++++++++++++++--- server/middleware/auth.js | 6 +- server/routes/filelist.js | 6 -- server/routes/ws.js | 8 +++ test/frontend/tests/workflow-tests.js | 5 +- 8 files changed, 118 insertions(+), 27 deletions(-) diff --git a/app/controller.js b/app/controller.js index 3943721d..1056f8ba 100644 --- a/app/controller.js +++ b/app/controller.js @@ -49,8 +49,8 @@ export default function(state, emitter) { state.user.login(email); }); - emitter.on('logout', () => { - state.user.logout(); + emitter.on('logout', async () => { + await state.user.logout(); metrics.loggedOut({ trigger: 'button' }); emitter.emit('pushState', '/'); }); @@ -178,6 +178,12 @@ export default function(state, emitter) { //cancelled. do nothing metrics.cancelledUpload(archive, err.duration); render(); + } else if (err.message === '401') { + const refreshed = await state.user.refresh(); + if (refreshed) { + return emitter.emit('upload'); + } + emitter.emit('pushState', '/error'); } else { // eslint-disable-next-line no-console console.error(err); diff --git a/app/ui/account.js b/app/ui/account.js index a81117e7..7f6430ec 100644 --- a/app/ui/account.js +++ b/app/ui/account.js @@ -54,12 +54,17 @@ class Account extends Component { createElement() { if (!this.enabled) { return html` -
+ `; } const user = this.state.user; const translate = this.state.translate; this.setLocal(); + if (user.loginRequired && !this.local.loggedIn) { + return html` + + `; + } if (!this.local.loggedIn) { return html` diff --git a/app/ui/signupDialog.js b/app/ui/signupDialog.js index 5d4c85d6..23fe9a66 100644 --- a/app/ui/signupDialog.js +++ b/app/ui/signupDialog.js @@ -53,13 +53,17 @@ module.exports = function(trigger) { type="submit" /> - + ${state.user.loginRequired + ? '' + : html` + + `} `; diff --git a/app/user.js b/app/user.js index c4303941..293f90c5 100644 --- a/app/user.js +++ b/app/user.js @@ -76,6 +76,10 @@ export default class User { return this.info.access_token; } + get refreshToken() { + return this.info.refresh_token; + } + get maxSize() { return this.loggedIn ? this.limits.MAX_FILE_SIZE @@ -135,6 +139,7 @@ export default class User { const code_challenge = await preparePkce(this.storage); const options = { action: 'email', + access_type: 'offline', client_id: this.authConfig.client_id, code_challenge, code_challenge_method: 'S256', @@ -192,12 +197,64 @@ export default class User { }); const userInfo = await infoResponse.json(); userInfo.access_token = auth.access_token; + userInfo.refresh_token = auth.refresh_token; userInfo.fileListKey = await getFileListKey(this.storage, auth.keys_jwe); this.info = userInfo; this.storage.remove('pkceVerifier'); } - logout() { + async refresh() { + if (!this.refreshToken) { + return false; + } + try { + const tokenResponse = await fetch(this.authConfig.token_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + client_id: this.authConfig.client_id, + grant_type: 'refresh_token', + refresh_token: this.refreshToken + }) + }); + const auth = await tokenResponse.json(); + this.info.access_token = auth.access_token; + return true; + } catch (e) { + return false; + } + } + + async logout() { + try { + if (this.refreshToken) { + await fetch(this.authConfig.revocation_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + refresh_token: this.refreshToken + }) + }); + } + if (this.bearerToken) { + await fetch(this.authConfig.revocation_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + token: this.bearerToken + }) + }); + } + } catch (e) { + console.error(e); + // oh well, we tried + } this.storage.clearLocalFiles(); this.info = {}; } @@ -211,17 +268,29 @@ export default class User { const key = b64ToArray(this.info.fileListKey); const sha = await crypto.subtle.digest('SHA-256', key); const kid = arrayToB64(new Uint8Array(sha)).substring(0, 16); + async function retry(e) { + if (e.message === '401') { + const refreshed = await this.refresh(); + if (refreshed) { + return await this.syncFileList(); + } else { + await this.logout(); + return { incoming: true }; + } + } + } try { - const encrypted = await getFileList(this.bearerToken, kid); + const encrypted = await getFileList( + this.bearerToken, + this.refreshToken, + kid + ); const decrypted = await streamToArrayBuffer( decryptStream(blobStream(encrypted), key) ); list = JSON.parse(textDecoder.decode(decrypted)); } catch (e) { - if (e.message === '401') { - this.logout(); - return { incoming: true }; - } + return retry(e); } changes = await this.storage.merge(list); if (!changes.outgoing) { @@ -234,9 +303,9 @@ export default class User { const encrypted = await streamToArrayBuffer( encryptStream(blobStream(blob), key) ); - await setFileList(this.bearerToken, kid, encrypted); + await setFileList(this.bearerToken, this.refreshToken, kid, encrypted); } catch (e) { - // + return retry(e); } return changes; } diff --git a/server/middleware/auth.js b/server/middleware/auth.js index 133b0992..c98d095b 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -70,6 +70,10 @@ module.exports = { const token = authHeader.split(' ')[1]; req.user = await fxa.verify(token); } - return next(); + if (req.user) { + next(); + } else { + res.sendStatus(401); + } } }; diff --git a/server/routes/filelist.js b/server/routes/filelist.js index 700fe745..043c8714 100644 --- a/server/routes/filelist.js +++ b/server/routes/filelist.js @@ -13,9 +13,6 @@ function id(user, kid) { module.exports = { async get(req, res) { - if (!req.user) { - return res.sendStatus(401); - } const kid = req.params.id; try { const fileId = id(req.user, kid); @@ -32,9 +29,6 @@ module.exports = { }, async post(req, res) { - if (!req.user) { - return res.sendStatus(401); - } const kid = req.params.id; try { const limiter = new Limiter(1024 * 1024 * 10); diff --git a/server/routes/ws.js b/server/routes/ws.js index 32ea7905..f56fad1d 100644 --- a/server/routes/ws.js +++ b/server/routes/ws.js @@ -41,6 +41,14 @@ module.exports = function(ws, req) { ? config.max_downloads : config.anon_max_downloads; + if (config.fxa_required && !user) { + ws.send( + JSON.stringify({ + error: 401 + }) + ); + return ws.close(); + } if ( !metadata || !auth || diff --git a/test/frontend/tests/workflow-tests.js b/test/frontend/tests/workflow-tests.js index d96e4ba0..3a9709c4 100644 --- a/test/frontend/tests/workflow-tests.js +++ b/test/frontend/tests/workflow-tests.js @@ -181,14 +181,15 @@ describe('Upload / Download flow', function() { it('can allow multiple downloads', async function() { const fs = new FileSender(); - const file = await fs.upload(archive); + const a = new Archive([blob]); + a.dlimit = 2; + const file = await fs.upload(a); const fr = new FileReceiver({ secretKey: file.toJSON().secretKey, id: file.id, nonce: file.keychain.nonce, requiresPassword: false }); - await file.changeLimit(2); await fr.getMetadata(); await fr.download(options); await file.updateDownloadCount();