From 91e92174f9b9119a8ed4f74a7b13471ed3567bce Mon Sep 17 00:00:00 2001 From: Martin Mason Date: Tue, 30 Aug 2016 11:43:28 -0500 Subject: [PATCH] feat(authService): add logout handling for openid connect OIDC providers like IdentityService and OpenIddict handle logout very similarly to authenticate. Feature adds logoutEndpoint to oAuth2 configuration and handles calling redirection to this endpoint very similarly to authenticate. --- src/authService.js | 24 ++++++++++++--- src/authentication.js | 9 ++++++ src/oAuth2.js | 31 +++++++++++++++++++ test/authService.spec.js | 50 ++++++++++++++++++++++++++++-- test/authentication.spec.js | 61 +++++++++++++++++++++++++++++++++++++ test/oAuth2.spec.js | 59 ++++++++++++++++++++++++++++++++--- 6 files changed, 222 insertions(+), 12 deletions(-) diff --git a/src/authService.js b/src/authService.js index 55871f8..72004b7 100644 --- a/src/authService.js +++ b/src/authService.js @@ -415,10 +415,12 @@ export class AuthService { * logout locally and redirect to redirectUri (if set) or redirectUri of config. Sends logout request first, if set in config * * @param {[String]} [redirectUri] [optional redirectUri overwrite] + * @param {[String]} [query] [optional query] + * @param {[String]} [name] [optional name Name of the provider] * * @return {Promise<>|Promise|Promise} Server response as Object */ - logout(redirectUri, query) { + logout(redirectUri, query, name) { let localLogout = response => new Promise(resolve => { this.setResponseObject(null); @@ -427,13 +429,25 @@ export class AuthService { if (typeof this.onLogout === 'function') { this.onLogout(response); } - resolve(response); }); - return (this.config.logoutUrl - ? this.client.request(this.config.logoutMethod, this.config.joinBase(this.config.logoutUrl)).then(localLogout) - : localLogout()); + if (name) { + if (this.config.providers[name].logoutEndpoint) { + return this.authentication.logout(name) + .then(logoutResponse => { + let stateValue = this.authentication.storage.get(name + '_state'); + if (logoutResponse.state !== stateValue) { + return Promise.reject('OAuth2 response state value differs'); + } + return localLogout(logoutResponse); + }); + } + } else { + return (this.config.logoutUrl + ? this.client.request(this.config.logoutMethod, this.config.joinBase(this.config.logoutUrl)).then(localLogout) + : localLogout()); + } } /** diff --git a/src/authentication.js b/src/authentication.js index 812efb7..1b589d5 100644 --- a/src/authentication.js +++ b/src/authentication.js @@ -1,3 +1,4 @@ +/// import {PLATFORM} from 'aurelia-pal'; import {buildQueryString} from 'aurelia-path'; import {inject} from 'aurelia-dependency-injection'; @@ -261,6 +262,14 @@ export class Authentication { return providerLogin.open(this.config.providers[name], userData); } + logout(name) { + let rtnValue = Promise.resolve('Not Applicable'); + if (this.config.providers[name].oauthType !== '2.0' || !this.config.providers[name].logoutEndpoint) { + return rtnValue; + } + return this.oAuth2.close(this.config.providers[name]); + } + redirect(redirectUrl, defaultRedirectUrl, query) { // stupid rule to keep it BC if (redirectUrl === true) { diff --git a/src/oAuth2.js b/src/oAuth2.js index e36ed58..0567f8a 100644 --- a/src/oAuth2.js +++ b/src/oAuth2.js @@ -100,6 +100,37 @@ export class OAuth2 { }); return query; } + + close(options) { + const provider = extend(true, {}, this.defaults, options); + const url = provider.logoutEndpoint + '?' + + buildQueryString(this.buildLogoutQuery(provider)); + const popup = this.popup.open(url, provider.name, provider.popupOptions); + const openPopup = (this.config.platform === 'mobile') + ? popup.eventListener(provider.postLogoutRedirectUri) + : popup.pollPopup(); + + return openPopup + .then(response => { + return response; + }); + } + + buildLogoutQuery(provider) { + let query = {}; + let authResponse = this.storage.get(this.config.storageKey); + + if (provider.postLogoutRedirectUri) { + query.post_logout_redirect_uri = provider.postLogoutRedirectUri; + } + if (this.storage.get(provider.name + '_state')) { + query.state = this.storage.get(provider.name + '_state'); + } + if (JSON.parse(authResponse).id_token) { + query.id_token_hint = JSON.parse(authResponse).id_token; + } + return query; + } } const camelCase = function(name) { diff --git a/test/authService.spec.js b/test/authService.spec.js index 8e0b7b3..74faf71 100644 --- a/test/authService.spec.js +++ b/test/authService.spec.js @@ -40,6 +40,17 @@ function getContainer() { return container; } +let oidcProviderConfig = { + providers: { + oidcProvider: { + name: 'oidcProvider', + oauthType: '2.0', + postLogoutRedirectUri: 'http://localhost:1927/', + logoutEndpoint: 'http://localhost:54540/connect/logout', + popupOptions: { width: 1028, height: 529 } + } + } +}; describe('AuthService', () => { describe('.constructor()', () => { @@ -794,6 +805,7 @@ describe('AuthService', () => { beforeEach(() => { authService.setResponseObject({token: 'some', refresh_token: 'another'}); authService.config.logoutRedirect = 'nowhere'; + authService.config.configure(oidcProviderConfig); }); afterEach(() => { @@ -806,6 +818,8 @@ describe('AuthService', () => { .then(() => { expect(authService.isAuthenticated()).toBe(false); + done(); + }, err => { done(); }); }); @@ -819,11 +833,41 @@ describe('AuthService', () => { expect(response.path).toBe('/auth/logout'); expect(response.method).toBe('GET'); + done(); + }, err => { done(); }); }); - }); + it('should call oAuth2.close() if logoutEndpoint defined', done => { + spyOn(authService.authentication.oAuth2, 'close').and.returnValue(Promise.resolve({ state: 'ThisIsTheState' })); + authService.config.logoutRedirect = false; + authService.authentication.storage.set('oidcProvider_state', 'ThisIsTheState'); + authService.logout(0, undefined, 'oidcProvider') + .then(response => { + expect(authService.authentication.oAuth2.close).toHaveBeenCalled(); + done(); + }, err => { + expect(err).toBeUndefined(); + done(); + }); + }); + + it('return reject Promise if states differ', done => { + spyOn(authService.authentication.oAuth2, 'close').and.callFake( () => { + return Promise.resolve({ state: 'ThisIsTheState' }); + }); + authService.authentication.storage.set('oidcProvider_state', 'ThisIsNotTheState'); + authService.logout(0, undefined, 'oidcProvider') + .then(response => { + expect(authService.authentication.oAuth2.close).toHaveBeenCalled(); + done(); + }, err => { + expect(err).toBe('OAuth2 response state value differs'); + done(); + }); + }); + }); describe('.authenticate()', () => { const container = getContainer(); @@ -852,7 +896,7 @@ describe('AuthService', () => { authService.authenticate('twitter', 0, {data: 'some'}) .then(response => { - expect(response.provider).toBe(authService.config.providers['twitter']); + expect(response.provider).toBe(authService.config.providers.twitter); expect(response.userData.data).toBe('some'); expect(response.access_token).toBe('oauth1'); @@ -868,7 +912,7 @@ describe('AuthService', () => { authService.authenticate('facebook', null, {data: 'some'}) .then(response => { - expect(response.provider).toBe(authService.config.providers['facebook']); + expect(response.provider).toBe(authService.config.providers.facebook); expect(response.userData.data).toBe('some'); expect(response.access_token).toBe('oauth2'); diff --git a/test/authentication.spec.js b/test/authentication.spec.js index 20ed3c7..247f06d 100644 --- a/test/authentication.spec.js +++ b/test/authentication.spec.js @@ -20,6 +20,17 @@ const tokenFuture = { jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidG9rZW5GdXR1cmUiLCJhZG1pbiI6dHJ1ZSwiZXhwIjoiMjQ2MDAxNzE1NCJ9.iHXLzWGY5U9WwVT4IVRLuKTf65XpgrA1Qq_Jlynv6bc' }; +const oidcProviderConfig = { + providers: { + oidcProvider: { + name: 'oidcProvider', + oauthType: '2.0', + postLogoutRedirectUri: 'http://localhost:1927/', + logoutEndpoint: 'http://localhost:54540/connect/logout', + popupOptions: { width: 1028, height: 529 } + } + } +}; describe('Authentication', () => { describe('.getResponseObject', () => { @@ -402,6 +413,56 @@ describe('Authentication', () => { }); }); + describe('.logout', () => { + const container = new Container(); + const authentication = container.get(Authentication); + + afterEach(() => { + authentication.setResponseObject(null); + }); + + it('should return Not Applicable when logoutEndpoint not defined', done => { + authentication.config.configure(oidcProviderConfig); + authentication.config.providers.oidcProvider.logoutEndpoint = null; + authentication.logout('oidcProvider') + .then( (value) => { + expect(value).toBe('Not Applicable'); + done(); + }) + .catch( err => { + done(); + }); + }); + + it('should return Not Applicable when oauthType not equal to 2.0', done => { + authentication.config.configure(oidcProviderConfig); + authentication.config.providers.oidcProvider.oauthType = '1.0'; + authentication.logout('oidcProvider') + .then( (value) => { + expect(value).toBe('Not Applicable'); + done(); + }) + .catch( err => { + done(); + }); + }); + + it('should return state', done => { + let stateValue = '123456789'; + authentication.config.configure(oidcProviderConfig); + spyOn(authentication.oAuth2, 'close').and.callFake(() => { + return Promise.resolve(stateValue); + }); + authentication.logout('oidcProvider') + .then( state => { + expect(state).toBe(stateValue); + done(); + }) + .catch( err => { + done(); + }); + }); + }); describe('.redirect', () => { const container = new Container(); diff --git a/test/oAuth2.spec.js b/test/oAuth2.spec.js index 5527399..9bf0ac3 100644 --- a/test/oAuth2.spec.js +++ b/test/oAuth2.spec.js @@ -28,6 +28,17 @@ function getContainer() { return container; } +let oidcProviderConfig = { + providers: { + oidcProvider: { + name: 'oidcProvider', + postLogoutRedirectUri: 'http://localhost:1927/', + logoutEndpoint: 'http://localhost:54540/connect/logout', + popupOptions: { width: 1028, height: 529 } + } + } +}; + describe('OAuth2', () => { const container = getContainer(); const storage = container.get(Storage); @@ -51,7 +62,7 @@ describe('OAuth2', () => { oAuth2.exchangeForToken( {access_token: 'someToken'}, {userData: 'some'}, - baseConfig.providers['facebook'] + baseConfig.providers.facebook ).then(res => { expect(res).toBeUndefined(); expect(false).toBe(true); @@ -68,7 +79,7 @@ describe('OAuth2', () => { oAuth2.exchangeForToken( {access_token: 'someToken'}, {userData: 'some'}, - baseConfig.providers['facebook'] + baseConfig.providers.facebook ).then(res => { expect(res).toBeDefined(); expect(res.path).toBe('/auth/facebook'); @@ -87,7 +98,7 @@ describe('OAuth2', () => { describe('.open()', () => { it('not fails with withCredentials = false', done => { baseConfig.withCredentials = false; - oAuth2.open(baseConfig.providers['facebook'], {userData: 'some'}) + oAuth2.open(baseConfig.providers.facebook, {userData: 'some'}) .then(res=>{ expect(res).toBeDefined(); expect(res.path).toBe('/auth/facebook'); @@ -107,9 +118,49 @@ describe('OAuth2', () => { describe('.buildQuery()', () => { it('return query', () => { - const query = oAuth2.buildQuery(baseConfig.providers['facebook']); + const query = oAuth2.buildQuery(baseConfig.providers.facebook); expect(query.display).toBe('popup'); expect(query.scope).toBe('email'); }); }); + + describe('.close()', () => { + it('logout popup url correct', done => { + const expectedIdToken = 'Some Id Token'; + const expectedLogoutRedirect = 'http://localhost:1927/'; + const expectedState = '1234567890'; + + spyOn(storage, 'get').and.callFake((key) => { + if (key === 'oidcProvider_state') { + return expectedState; + } + if (key === oAuth2.config.storageKey) { + return `{ "id_token": "${expectedIdToken}" }`; + } + }); + oAuth2.close(oidcProviderConfig.providers.oidcProvider) + .then(res => { + const expectedUrl = `http://localhost:54540/connect/logout?id_token_hint=${encodeURIComponent(expectedIdToken)}&post_logout_redirect_uri=${encodeURIComponent(expectedLogoutRedirect)}&state=${encodeURIComponent(expectedState)}`; + expect(popup.url).toBe(expectedUrl); + done(); + }); + }); + }); + + describe('.buildLogoutQuery()', () => { + it('return query parameters', () => { + spyOn(storage, 'get').and.callFake((key) => { + if (key === 'oidcProvider_state') { + return '123456789'; + } + if (key === oAuth2.config.storageKey) { + return '{ "id_token": "IdTokenHere" }'; + } + }); + const query = oAuth2.buildLogoutQuery(oidcProviderConfig.providers.oidcProvider); + expect(query.post_logout_redirect_uri).toBe('http://localhost:1927/'); + expect(query.state).toBe('123456789'); + expect(query.id_token_hint).toBe('IdTokenHere'); + }); + }); });