Browse Source

added oauth refresh token support

Co-authored-by: timvisee <tim@visee.me>
Danny Coates 4 years ago
parent
commit
4f273eca03

+ 8 - 2
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);

+ 6 - 1
app/ui/account.js

@@ -54,12 +54,17 @@ class Account extends Component {
   createElement() {
     if (!this.enabled) {
       return html`
-        <div></div>
+        <send-account></send-account>
       `;
     }
     const user = this.state.user;
     const translate = this.state.translate;
     this.setLocal();
+    if (user.loginRequired && !this.local.loggedIn) {
+      return html`
+        <send-account></send-account>
+      `;
+    }
     if (!this.local.loggedIn) {
       return html`
         <send-account>

+ 11 - 7
app/ui/signupDialog.js

@@ -53,13 +53,17 @@ module.exports = function(trigger) {
               type="submit"
             />
           </form>
-          <button
-            class="my-3 link-blue font-medium"
-            title="${state.translate('deletePopupCancel')}"
-            onclick=${cancel}
-          >
-            ${state.translate('deletePopupCancel')}
-          </button>
+          ${state.user.loginRequired
+            ? ''
+            : html`
+                <button
+                  class="my-3 link-blue font-medium"
+                  title="${state.translate('deletePopupCancel')}"
+                  onclick=${cancel}
+                >
+                  ${state.translate('deletePopupCancel')}
+                </button>
+              `}
         </section>
       </send-signup-dialog>
     `;

+ 77 - 8
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;
   }

+ 5 - 1
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);
+    }
   }
 };

+ 0 - 6
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);

+ 8 - 0
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 ||

+ 3 - 2
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();