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