From 110fdf392277529d1e4c41345cc8131948f33630 Mon Sep 17 00:00:00 2001 From: Mathias Ciarlo Date: Fri, 20 Sep 2019 17:24:15 +0200 Subject: [PATCH] feat(oauth2): Add oauth2 refresh support --- examples/demo/nuxt.config.js | 3 +- examples/demo/pages/signed-out.vue | 14 +++++ lib/core/auth.js | 22 ++++++++ lib/core/utilities.js | 6 ++ lib/module/plugin.js | 9 +++ lib/schemes/oauth2.js | 89 +++++++++++++++++++++++++++++- package.json | 1 + yarn.lock | 5 ++ 8 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 examples/demo/pages/signed-out.vue diff --git a/examples/demo/nuxt.config.js b/examples/demo/nuxt.config.js index ed4795154..90403c88e 100644 --- a/examples/demo/nuxt.config.js +++ b/examples/demo/nuxt.config.js @@ -17,7 +17,8 @@ module.exports = { }, auth: { redirect: { - callback: '/callback' + callback: '/callback', + logout: '/signed-out' }, strategies: { local: { diff --git a/examples/demo/pages/signed-out.vue b/examples/demo/pages/signed-out.vue new file mode 100644 index 000000000..d08fdb328 --- /dev/null +++ b/examples/demo/pages/signed-out.vue @@ -0,0 +1,14 @@ + + + diff --git a/lib/core/auth.js b/lib/core/auth.js index 4e12a0304..17def53e6 100644 --- a/lib/core/auth.js +++ b/lib/core/auth.js @@ -229,6 +229,28 @@ export default class Auth { return this.$storage.syncUniversal(_key) } + // --------------------------------------------------------------- + // Refresh token helpers + // --------------------------------------------------------------- + + getRefreshToken (strategy) { + const _key = this.options.refresh_token.prefix + strategy + + return this.$storage.getUniversal(_key) + } + + setRefreshToken (strategy, refreshToken) { + const _key = this.options.refresh_token.prefix + strategy + + return this.$storage.setUniversal(_key, refreshToken) + } + + syncRefreshToken (strategy) { + const _key = this.options.refresh_token.prefix + strategy + + return this.$storage.syncUniversal(_key) + } + // --------------------------------------------------------------- // User helpers // --------------------------------------------------------------- diff --git a/lib/core/utilities.js b/lib/core/utilities.js index 52e0430ad..bc2810a95 100644 --- a/lib/core/utilities.js +++ b/lib/core/utilities.js @@ -83,3 +83,9 @@ export function decodeValue (val) { // Return as is return val } + +export class ExpiredSessionError extends Error { + constructor(message) { + super(message) + } +} diff --git a/lib/module/plugin.js b/lib/module/plugin.js index e1c4bdff5..d1c861f1c 100644 --- a/lib/module/plugin.js +++ b/lib/module/plugin.js @@ -1,6 +1,7 @@ import Auth from './auth' import './middleware' +import { ExpiredSessionError } from './utilities' // Active schemes <%= options.uniqueSchemes.map(path =>`import ${'scheme_' + hash(path)} from '${path.replace(/\\/g,'/')}'`).join('\n') %> @@ -29,6 +30,14 @@ export default function (ctx, inject) { // Initialize auth return $auth.init().catch(error => { if (process.client) { + + // Don't console log expired session errors. This error is common, and expected to happen. + // The error happens whenever the user does an ssr request (reload/initial navigation) with an expired refresh + // token. We don't want to log this as an error. + if (error instanceof ExpiredSessionError) { + return + } + console.error('[ERROR] [AUTH]', error) } }) diff --git a/lib/schemes/oauth2.js b/lib/schemes/oauth2.js index 9d8cc2d80..26e76a22b 100644 --- a/lib/schemes/oauth2.js +++ b/lib/schemes/oauth2.js @@ -1,5 +1,6 @@ -import { encodeQuery, parseQuery } from '../utilities' +import { encodeQuery, parseQuery, ExpiredSessionError } from '../utilities' import nanoid from 'nanoid' +import jwtDecode from 'jwt-decode' const isHttps = process.server ? require('is-https') : null const DEFAULTS = { @@ -42,11 +43,14 @@ export default class Oauth2Scheme { } async mounted () { - // Sync token + // Sync tokens const token = this.$auth.syncToken(this.name) + this.$auth.syncRefreshToken(this.name) + // Set axios token if (token) { this._setToken(token) + this.initializeTokenRefreshOnRequest() } // Handle callbacks on page load @@ -198,4 +202,85 @@ export default class Oauth2Scheme { return true // True means a redirect happened } + + // --------------------------------------------------------------- + // Watch axios requests for token expiration + // Refresh tokens if token has expired + // --------------------------------------------------------------- + + initializeTokenRefreshOnRequest () { + const { $axios } = this.$auth.ctx.app + let isRefreshing = false + + $axios.onRequest(async config => { + let token = this.$auth.getToken(this.name) + let refreshToken = this.$auth.getRefreshToken(this.name) + + // Token or "refresh token" does not exists + if (!token || !refreshToken || !token.length || !refreshToken.length) { + return config + } + + // If already trying to refresh token, do not try again + if (isRefreshing) { + return config + } + + // Time variables + let tokenExpiresAt = jwtDecode(token).exp * 1000 + let refreshTokenExpiresAt = jwtDecode(refreshToken).exp * 1000 + const now = Date.now() + + // Give us some slack to help the token from expiring between validation and usage + const timeSlackMillis = 500 + tokenExpiresAt -= timeSlackMillis + refreshTokenExpiresAt -= timeSlackMillis + + + // Return if token has not expired + if (now < tokenExpiresAt) { + return config + } + // "Refresh token" has also expired. There is no way to refresh. Force logout. + if (now > refreshTokenExpiresAt) { + this.logout() + + if(process.client) { + // Explicitly redirect to the signed-out page. + // We don't want to redirect a user with an expired token on page reload/first navigation. + // WatchLoggedIn in auth.js->mounted redirects to logout when the state changes, + // but it only works in the client + this.$auth.redirect('logout') + } + // Stop the request from happening. The original caller must catch ExpiredSessionErrors + throw new ExpiredSessionError() + } + + // Try to refresh token before processing current request + isRefreshing = true + + return $axios.post(this.options.access_token_endpoint, + encodeQuery({ + refresh_token: refreshToken.replace(this.options.token_type + ' ', ''), + client_id: this.options.client_id, + grant_type: 'refresh_token' + }) + ).then(response => { + isRefreshing = false + + // Update token and "refresh token" + token = this.options.token_type + ' ' + response.data.access_token + refreshToken = this.options.token_type + ' ' + response.data.refresh_token + + this.$auth.setToken(this.name, token) + this.$auth.setRefreshToken(this.name, refreshToken) + $axios.setToken(token) + + // Update token for current request and process it + config.headers['Authorization'] = token + + return Promise.resolve(config) + }) + }) + } } diff --git a/package.json b/package.json index b514497a3..5ee0ab49a 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "dotprop": "^1.2.0", "is-https": "^1.0.0", "js-cookie": "^2.2.1", + "jwt-decode": "^2.2.0", "lodash": "^4.17.15", "nanoid": "^2.1.1" }, diff --git a/yarn.lock b/yarn.lock index 8d16b87e0..2b215724f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6073,6 +6073,11 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +jwt-decode@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" + integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk= + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"