From 77bdbe498136809f2bdb827ffc43f7b3ba277ccc Mon Sep 17 00:00:00 2001 From: Alfred Neequaye Date: Sun, 27 Mar 2016 08:31:35 +0000 Subject: [PATCH] feat(project): add refresh token --- README.md | 15 +++++ src/app.fetch-httpClient.config.js | 40 +++++++++--- src/authService.js | 77 ++++++++++++++++++++---- src/authentication.js | 59 +++++++++++++++++- src/baseConfig.js | 15 +++++ test/app.fetch-httpClient.config.spec.js | 7 ++- 6 files changed, 188 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 5c5e9e2..80f8c07 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,21 @@ unlinkUrl: '/unlink/', // The HTTP method used for 'unlink' requests (Options: 'get' or 'post') unlinkMethod: 'get', +// Refresh Token Options +// ===================== + +// Option to turn refresh tokens On/Off +useRefreshToken: false, +// The option to enable/disable the automatic refresh of Auth tokens using Refresh Tokens +autoUpdateToken: true, +// This allows the refresh token to be a further object deeper `{ "responseTokenProp": { "refreshTokenRoot" : { "tokenName" : '...' } } }` +refreshTokenRoot: false, +// This is the property from which to get the token `{ "responseTokenProp": { "refreshTokenName" : '...' } }` +refreshTokenName: 'refresh_token', +// Prepended to the `refreshTokenName` when kept in storage (nothing to do with) +refreshTokenPrefix: 'aurelia', +// Oauth Client Id +clientId: false, // Token Related options // ===================== diff --git a/src/app.fetch-httpClient.config.js b/src/app.fetch-httpClient.config.js index b5940f3..0ccb8da 100644 --- a/src/app.fetch-httpClient.config.js +++ b/src/app.fetch-httpClient.config.js @@ -1,10 +1,10 @@ import {HttpClient} from 'aurelia-fetch-client'; -import {Authentication} from './authentication'; +import {AuthService} from './authService'; import {BaseConfig} from './baseConfig'; import {inject} from 'aurelia-dependency-injection'; import {Config, Rest} from 'spoonx/aurelia-api'; -@inject(HttpClient, Config, Authentication, BaseConfig) +@inject(HttpClient, Config, AuthService, BaseConfig) export class FetchConfig { /** * Construct the FetchConfig @@ -14,10 +14,10 @@ export class FetchConfig { * @param {Authentication} authService * @param {BaseConfig} config */ - constructor(httpClient, clientConfig, authentication, config) { + constructor(httpClient, clientConfig, authService, config) { this.httpClient = httpClient; this.clientConfig = clientConfig; - this.auth = authentication; + this.auth = authService; this.config = config.current; } @@ -27,16 +27,16 @@ export class FetchConfig { * @return {{request: Function}} */ get interceptor() { - let auth = this.auth; - let config = this.config; + let auth = this.auth; + let config = this.config; + let client = this.httpClient; return { request(request) { if (!auth.isAuthenticated() || !config.httpInterceptor) { return request; } - - let token = auth.getToken(); + let token = auth.getCurrentToken(); if (config.authHeader && config.authToken) { token = `${config.authToken} ${token}`; @@ -45,6 +45,30 @@ export class FetchConfig { request.headers.append(config.authHeader, token); return request; + }, + response(response, request) { + return new Promise((resolve, reject) => { + if (response.ok) { + return resolve(response); + } + if (response.status !== 401) { + return resolve(response); + } + if (!auth.isTokenExpired() || !config.httpInterceptor) { + return resolve(response); + } + if (!auth.getRefreshToken()) { + return resolve(response); + } + auth.updateToken().then(() => { + let token = auth.getCurrentToken(); + if (config.authHeader && config.authToken) { + token = `${config.authToken} ${token}`; + } + request.headers.append('Authorization', token); + return client.fetch(request).then(resolve); + }); + }); } }; } diff --git a/src/authService.js b/src/authService.js index 3a498de..0fba7b3 100644 --- a/src/authService.js +++ b/src/authService.js @@ -8,11 +8,12 @@ import {authUtils} from './authUtils'; @inject(Authentication, OAuth1, OAuth2, BaseConfig) export class AuthService { constructor(auth, oAuth1, oAuth2, config) { - this.auth = auth; - this.oAuth1 = oAuth1; - this.oAuth2 = oAuth2; - this.config = config.current; - this.client = this.config.client; + this.auth = auth; + this.oAuth1 = oAuth1; + this.oAuth2 = oAuth2; + this.config = config.current; + this.client = this.config.client; + this.isRefreshing = false; } getMe(criteria) { @@ -22,17 +23,36 @@ export class AuthService { return this.client.find(this.auth.getProfileUrl(), criteria); } + getCurrentToken() { + return this.auth.getToken(); + } + + getRefreshToken() { + return this.auth.getRefreshToken(); + } + updateMe(body, criteria) { if (typeof criteria === 'string' || typeof criteria === 'number') { - criteria = {id: criteria}; + criteria = { id: criteria }; } return this.client.update(this.auth.getProfileUrl(), criteria, body); } isAuthenticated() { + let isExpired = this.auth.isTokenExpired(); + if (isExpired && this.config.autoUpdateToken) { + if (this.isRefreshing) { + return true; + } + this.updateToken(); + } return this.auth.isAuthenticated(); } + isTokenExpired() { + return this.auth.isTokenExpired(); + } + getTokenPayload() { return this.auth.getPayload(); } @@ -63,28 +83,61 @@ export class AuthService { login(email, password) { let loginUrl = this.auth.getLoginUrl(); - let content; + let config = this.config; + let clientId = this.config.clientId; + let content = {}; if (typeof arguments[1] !== 'string') { content = arguments[0]; } else { - content = { - 'email': email, - 'password': password - }; + content = {email: email, password: password}; + if (clientId) { + content.client_id = clientId; + } } return this.client.post(loginUrl, content) .then(response => { this.auth.setTokenFromResponse(response); + if (config.useRefreshToken) { + this.auth.setRefreshTokenFromResponse(response); + } return response; }); } - logout(redirectUri) { return this.auth.logout(redirectUri); } + updateToken() { + this.isRefreshing = true; + let loginUrl = this.auth.getLoginUrl(); + let refreshToken = this.auth.getRefreshToken(); + let clientId = this.config.clientId; + let content = {}; + if (refreshToken) { + content = {grant_type: 'refresh_token', refresh_token: refreshToken}; + if (clientId) { + content.client_id = clientId; + } + + return this.client.post(loginUrl, content) + .then(response => { + this.auth.setRefreshToken(response); + this.auth.setToken(response); + this.isRefreshing = false; + + return response; + }).catch((err) => { + this.auth.removeToken(); + this.auth.removeRefreshToken(); + this.isRefreshing = false; + + throw err; + }); + } + } + authenticate(name, redirect, userData) { let provider = this.oAuth2; if (this.config.providers[name].type === '1.0') { diff --git a/src/authentication.js b/src/authentication.js index c29ce1a..8effeed 100644 --- a/src/authentication.js +++ b/src/authentication.js @@ -6,8 +6,12 @@ import {authUtils} from './authUtils'; @inject(Storage, BaseConfig) export class Authentication { constructor(storage, config) { - this.storage = storage; - this.config = config.current; + this.storage = storage; + this.config = config.current; + } + + get refreshTokenName() { + return this.config.refreshTokenPrefix ? this.config.refreshTokenPrefix + '_' + this.config.refreshTokenName : this.config.refreshTokenName; } get tokenName() { @@ -38,13 +42,16 @@ export class Authentication { return this.storage.get(this.tokenName); } + getRefreshToken() { + return this.storage.get(this.refreshTokenName); + } + getPayload() { let token = this.storage.get(this.tokenName); if (token && token.split('.').length === 3) { let base64Url = token.split('.')[1]; let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - try { return JSON.parse(decodeURIComponent(escape(window.atob(base64)))); } catch (error) { @@ -85,10 +92,44 @@ export class Authentication { } } + setRefreshTokenFromResponse(response) { + let refreshTokenName = this.refreshTokenName; + let refreshToken = response && response.refresh_token; + let refreshTokenPath; + let token; + + if (refreshToken) { + if (authUtils.isObject(refreshToken) && authUtils.isObject(refreshToken.data)) { + response = refreshToken; + } else if (authUtils.isString(refreshToken)) { + token = refreshToken; + } + } + + if (!token && response) { + token = this.config.refreshTokenRoot && response[this.config.refreshTokenRoot] + ? response[this.config.refreshTokenRoot][this.config.refreshTokenName] + : response[this.config.refreshTokenName]; + } + if (!token) { + refreshTokenPath = this.config.refreshTokenRoot + ? this.config.refreshTokenRoot + '.' + this.config.refreshTokenName + : this.config.refreshTokenName; + + throw new Error('Expecting a refresh token named "' + refreshTokenPath + '" but instead got: ' + JSON.stringify(response.content)); + } + + this.storage.set(refreshTokenName, token); + } + removeToken() { this.storage.remove(this.tokenName); } + removeRefreshToken() { + this.storage.remove(this.refreshTokenName); + } + isAuthenticated() { let token = this.storage.get(this.tokenName); @@ -119,9 +160,21 @@ export class Authentication { return true; } + + isTokenExpired() { + let payload = this.getPayload(); + let exp = payload ? payload.exp : null; + if (exp) { + return Math.round(new Date().getTime() / 1000) > exp; + } + + return undefined; + } + logout(redirect) { return new Promise(resolve => { this.storage.remove(this.tokenName); + this.storage.remove(this.refreshTokenName); if (this.config.logoutRedirect && !redirect) { window.location.href = this.config.logoutRedirect; diff --git a/src/baseConfig.js b/src/baseConfig.js index a43e9ad..8b89329 100644 --- a/src/baseConfig.js +++ b/src/baseConfig.js @@ -56,6 +56,21 @@ export class BaseConfig { // The HTTP method used for 'unlink' requests (Options: 'get' or 'post') unlinkMethod: 'get', + // Refresh Token Options + // ===================== + + // Option to turn refresh tokens On/Off + useRefreshToken: false, + // The option to enable/disable the automatic refresh of Auth tokens using Refresh Tokens + autoUpdateToken: true, + // This allows the refresh token to be a further object deeper `{ "responseTokenProp": { "refreshTokenRoot" : { "tokenName" : '...' } } }` + refreshTokenRoot: false, + // This is the property from which to get the token `{ "responseTokenProp": { "refreshTokenName" : '...' } }` + refreshTokenName: 'refresh_token', + // Prepended to the `refreshTokenName` when kept in storage (nothing to do with) + refreshTokenPrefix: 'aurelia', + // Oauth Client Id + clientId: false, // Token Related options // ===================== diff --git a/test/app.fetch-httpClient.config.spec.js b/test/app.fetch-httpClient.config.spec.js index 245d856..01ebf00 100644 --- a/test/app.fetch-httpClient.config.spec.js +++ b/test/app.fetch-httpClient.config.spec.js @@ -15,10 +15,13 @@ function getContainer() { return container; } -function getInterceptorStubs(isAuthenticated, httpInterceptor) { +function getInterceptorStubs(isAuthenticated, httpInterceptor, refreshTokenExists) { let authenticationStub = { isAuthenticated: () => { return isAuthenticated; }, - getToken: () => { return 'someToken'; } + getCurrentToken: () => { return 'someToken'; }, + updateToken: () => { + return refreshTokenExists ? Promise.resolve() : Promise.reject(); + } }; let configStub = {