From e79d76315bb92804b45a229a06d0e3cdaf0556ff Mon Sep 17 00:00:00 2001 From: Nick Woodward Date: Sat, 9 May 2020 20:07:33 -0500 Subject: [PATCH] fix: the endpoints property will now work properly - Resolved an issue with enhancing a request with an access token while not authenticated. with the current Access Token. - Login and logout requests are now deduped to prevent issues with state and nonce collisions. - Updated multiple `.name` values to use `.$name` --- .travis.yml | 5 +- demo/index.ts | 9 +- demo/redirect.ts | 16 +- src/base/core/provider.ts | 6 +- src/base/provider-oauth2.ts | 4 +- src/base/provider-openid.ts | 30 ++- src/salte-auth.ts | 203 +++++++++--------- src/utils/dedupe.ts | 30 +++ src/utils/index.ts | 1 + test/unit/base/provider-oauth2.spec.js | 9 +- test/unit/base/provider-openid.spec.js | 14 +- test/unit/salte-auth.spec.js | 272 +++++++++++++++---------- test/unit/utils/dedupe.spec.js | 49 +++++ test/utils/wait.ts | 3 + 14 files changed, 401 insertions(+), 250 deletions(-) create mode 100644 src/utils/dedupe.ts create mode 100644 test/unit/utils/dedupe.spec.js create mode 100644 test/utils/wait.ts diff --git a/.travis.yml b/.travis.yml index bd8994ac..e4b93bcc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,11 +5,12 @@ cache: npm branches: only: - master + - /^ci/.*$/ script: - npm start lint - npm start check-types - - if [ "${TRAVIS_PULL_REQUEST}" = "false" ] && [ "${TRAVIS_BRANCH}" = "master" ]; then npm start test.ci; fi - - if [ "${TRAVIS_PULL_REQUEST}" = "false" ] && [ "${TRAVIS_BRANCH}" = "master" ]; then npm start test.smoke; fi + - if [ "${TRAVIS_PULL_REQUEST}" = "false" ]; then npm start test.ci; fi + - if [ "${TRAVIS_PULL_REQUEST}" = "false" ]; then npm start test.smoke; fi - npm start test - npm start build.docs after_success: diff --git a/demo/index.ts b/demo/index.ts index cf1dd1f6..252c2193 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -33,6 +33,8 @@ const auth = new SalteAuth({ return null; }, + + level: 'trace', }), new Generic.OAuth2({ @@ -48,14 +50,15 @@ const auth = new SalteAuth({ handlers: [ new Redirect({ default: true, - navigate: 'history', }) ], + + level: 'trace', }); auth.on('login', (error, data) => { - if (error) console.error(error); - else console.log(data); + if (error) console.error('[login]:', error); + else console.log('[login]:', data); }); const loginButton = document.createElement('button'); diff --git a/demo/redirect.ts b/demo/redirect.ts index 15338c31..11b28d87 100644 --- a/demo/redirect.ts +++ b/demo/redirect.ts @@ -1,4 +1,4 @@ -import { Handler, SalteAuthError, Utils, OAuth2Provider, OpenIDProvider } from '../src/salte-auth'; +import { Handler, SalteAuthError, Utils } from '../src/salte-auth'; export class Redirect extends Handler { public constructor(config?: Redirect.Config) { @@ -17,7 +17,7 @@ export class Redirect extends Handler { return true; } - public connected({ action }: Handler.ConnectedOptions): OAuth2Provider.Validation | OpenIDProvider.Validation | void { + public connected({ action }: Handler.ConnectedOptions) { if (!action) return; const origin = this.storage.get('origin'); @@ -26,17 +26,17 @@ export class Redirect extends Handler { this.storage.delete('origin'); - const parsed = Utils.URL.parse(location); - - this.navigate(origin); - if (action === 'login') { + // Does it make sense to navigate on 'logout'? + // NOTE: This order, matters since navigate modifies the location. + const parsed = Utils.URL.parse(location); + this.navigate(origin); return parsed; } } - public async open({ url, timeout = this.config.timeout }: Redirect.OpenOptions): Promise { - this.storage.set('origin', location.href.replace(location.hash, '')); + public open({ url, timeout = this.config.timeout }: Redirect.OpenOptions): Promise { + this.storage.set('origin', location.href); this.navigate(url); diff --git a/src/base/core/provider.ts b/src/base/core/provider.ts index 295a700e..7c5224d8 100644 --- a/src/base/core/provider.ts +++ b/src/base/core/provider.ts @@ -1,6 +1,6 @@ import { Shared } from './shared'; -import { Common, Interceptors, Logger, URL } from '../../utils'; +import { Common, Interceptors, Logger, URL, Dedupe } from '../../utils'; import { SalteAuthError } from './salte-auth-error'; export class Provider extends Shared { @@ -48,6 +48,8 @@ export class Provider extends Shared { protected url = URL.url; + public dedupe = Dedupe.dedupe(); + /** * Returns the logout url for the provider. */ @@ -96,7 +98,7 @@ export interface Provider { * Invoked when an endpoint is marked as secured. * @returns true if the endpoint is already secured, otherwise it returns a url to secure the endpoint. */ - secure?(request?: Interceptors.XHR.ExtendedXMLHttpRequest | Request): Promise; + secure?(request?: Interceptors.XHR.ExtendedXMLHttpRequest | Request): Promise<'login' | boolean>; on(name: 'login', listener: (error?: Error, data?: any) => void): void; on(name: 'logout', listener: (error?: Error) => void): void; diff --git a/src/base/provider-oauth2.ts b/src/base/provider-oauth2.ts index ea6a563a..97956058 100644 --- a/src/base/provider-oauth2.ts +++ b/src/base/provider-oauth2.ts @@ -17,10 +17,10 @@ export class OAuth2Provider extends Provider { this.required('clientID', 'responseType'); } - public async secure(request: Interceptors.XHR.ExtendedXMLHttpRequest | Request): Promise { + public async secure(request: Interceptors.XHR.ExtendedXMLHttpRequest | Request): Promise<'login' | boolean> { if (this.config.responseType === 'token') { if (this.accessToken.expired) { - return this.$login(); + return 'login'; } if (request) { diff --git a/src/base/provider-openid.ts b/src/base/provider-openid.ts index d93cd6c2..fc0dc2ef 100644 --- a/src/base/provider-openid.ts +++ b/src/base/provider-openid.ts @@ -26,22 +26,30 @@ export class OpenIDProvider extends OAuth2Provider { this.sync(); } - public async secure(request: Interceptors.XHR.ExtendedXMLHttpRequest | Request): Promise { + public async secure(request: Interceptors.XHR.ExtendedXMLHttpRequest | Request): Promise<'login' | boolean> { if (Common.includes(['id_token', 'id_token token', 'token'], this.config.responseType)) { if (this.idToken.expired) { - return this.$login(); + this.logger.trace('[secure]: ID Token has expired, requesting login...'); + + return 'login'; } if (this.accessToken.expired) { - const parsed = await Common.iframe({ - redirectUrl: this.redirectUrl('login'), - url: this.$login({ - prompt: 'none', - responseType: 'token', - }), - }); - - this.validate(parsed); + await this.dedupe('access-token', async () => { + this.logger.info(`[secure]: Expired access token detected, retrieving...`); + + const parsed = await Common.iframe({ + redirectUrl: this.redirectUrl('login'), + url: this.$login({ + prompt: 'none', + responseType: 'token', + }), + }); + + this.logger.info(`[secure]: Access token retrieved! Validating...`); + + this.validate(parsed); + }) } if (request) { diff --git a/src/salte-auth.ts b/src/salte-auth.ts index e968708b..b445294c 100644 --- a/src/salte-auth.ts +++ b/src/salte-auth.ts @@ -29,14 +29,14 @@ export class SalteAuth extends Shared { provider.on('login', (error, data) => { this.emit('login', error, { - provider: provider.name, + provider: provider.$name, data: data }); }); provider.on('logout', (error) => { this.emit('logout', error, { - provider: provider.name + provider: provider.$name }); }); }); @@ -57,21 +57,27 @@ export class SalteAuth extends Shared { const responsible = handler.$name === handlerName; - setTimeout(() => { - const parsed = handler.connected({ action: responsible ? action : null }); + if (responsible) { + provider.dedupe(action, async () => { + this.logger.trace(`[constructor]: wrapping up authentication for ${handler.$name}...`); - if (!responsible) return; + await new Promise((resolve) => setTimeout(resolve)); - if (action === 'login') { - provider.validate(parsed); - this.logger.info('[constructor]: login complete'); - } else { - provider.storage.clear(); - provider.sync(); - provider.emit('logout'); - this.logger.info('[constructor]: logout complete'); - } - }); + const parsed = handler.connected({ action }); + + if (action === 'login') { + provider.validate(parsed); + this.logger.info('[constructor]: login complete'); + } else { + provider.storage.clear(); + provider.sync(); + provider.emit('logout'); + this.logger.info('[constructor]: logout complete'); + } + }); + } else { + handler.connected({ action: null }); + } }); this.storage.delete('action'); @@ -83,7 +89,7 @@ export class SalteAuth extends Shared { const provider = this.config.providers[i]; if (URL.match(request.url, provider.config.endpoints)) { - provider.secure && await provider.secure(request); + await this.$secure(provider, request); } } }); @@ -93,55 +99,18 @@ export class SalteAuth extends Shared { const provider = this.config.providers[i]; if (URL.match(request.$url, provider.config.endpoints)) { - provider.secure && await provider.secure(request); + await this.$secure(provider, request); } } }); Events.route(async () => { - try { - const handler = this.handler(); - - for (let i = 0; i < this.config.providers.length; i++) { - const provider = this.config.providers[i]; - - if (URL.match(location.href, provider.config.routes)) { - let response: string | boolean = null; - - while (response !== true) { - response = await provider.secure(); - - if (typeof(response) === 'string') { - if (!handler.auto) { - throw new SalteAuthError({ - code: 'auto_unsupported', - message: `The default handler doesn't support automatic authentication! (${handler.$name})`, - }); - } - - this.storage.set('action', 'login'); - this.storage.set('provider', provider.$name); - this.storage.set('handler', handler.$name); - - const params = await handler.open({ - redirectUrl: provider.redirectUrl('login'), - url: response, - }); - - provider.validate(params); + for (let i = 0; i < this.config.providers.length; i++) { + const provider = this.config.providers[i]; - this.storage.delete('action'); - this.storage.delete('provider'); - this.storage.delete('handler'); - } - } - } + if (URL.match(location.href, provider.config.routes)) { + await this.$secure(provider); } - } catch (error) { - this.storage.delete('action'); - this.storage.delete('provider'); - this.storage.delete('handler'); - throw error; } }); @@ -161,29 +130,34 @@ export class SalteAuth extends Shared { */ public async login(provider: string): Promise; public async login(options: SalteAuth.AuthOptions | string): Promise { - options = typeof(options) === 'string' ? { provider: options } : options; + const normalizedOptions: SalteAuth.AuthOptions = typeof(options) === 'string' ? { provider: options } : options; - try { - const provider = this.provider(options.provider); - const handler = this.handler(options.handler); + const provider = this.provider(normalizedOptions.provider); - this.storage.set('action', 'login'); - this.storage.set('provider', provider.$name); - this.storage.set('handler', handler.$name); + return provider.dedupe('login', async () => { + const handler = this.handler(normalizedOptions.handler); - this.logger.info(`[login]: logging in with ${provider.$name} via ${handler.$name}...`); - const params = await handler.open({ - redirectUrl: provider.redirectUrl('login'), - url: provider.$login(), - }); + try { + this.storage.set('action', 'login'); + this.storage.set('provider', provider.$name); + this.storage.set('handler', handler.$name); + + this.logger.info(`[login]: logging in with ${provider.$name} via ${handler.$name}...`); + const params = await handler.open({ + redirectUrl: provider.redirectUrl('login'), + url: provider.$login(), + }); - provider.validate(params); - this.logger.info('[login]: login complete'); - } finally { - this.storage.delete('action'); - this.storage.delete('provider'); - this.storage.delete('handler'); - } + this.logger.trace(`[login]: validating response...`, params); + + provider.validate(params); + this.logger.info('[login]: login complete'); + } finally { + this.storage.delete('action'); + this.storage.delete('provider'); + this.storage.delete('handler'); + } + }); } /** @@ -199,34 +173,37 @@ export class SalteAuth extends Shared { */ public async logout(provider: string): Promise; public async logout(options: SalteAuth.AuthOptions | string): Promise { - options = typeof(options) === 'string' ? { provider: options } : options; + const normalizedOptions: SalteAuth.AuthOptions = typeof(options) === 'string' ? { provider: options } : options; - const provider = this.provider(options.provider); - try { - const handler = this.handler(options.handler); + const provider = this.provider(normalizedOptions.provider); - this.storage.set('action', 'logout'); - this.storage.set('provider', provider.$name); - this.storage.set('handler', handler.$name); + return provider.dedupe('logout', async () => { + try { + const handler = this.handler(normalizedOptions.handler); - this.logger.info(`[logout]: logging out with ${provider.$name} via ${handler.$name}...`); - await handler.open({ - redirectUrl: provider.redirectUrl('logout'), - url: URL.url(provider.logout, provider.config.queryParams && provider.config.queryParams('logout')), - }); + this.storage.set('action', 'logout'); + this.storage.set('provider', provider.$name); + this.storage.set('handler', handler.$name); - provider.storage.clear(); - provider.sync(); - provider.emit('logout'); - this.logger.info('[logout]: logout complete'); - } catch (error) { - provider.emit('logout', error); - throw error; - } finally { - this.storage.delete('action'); - this.storage.delete('provider'); - this.storage.delete('handler'); - } + this.logger.info(`[logout]: logging out with ${provider.$name} via ${handler.$name}...`); + await handler.open({ + redirectUrl: provider.redirectUrl('logout'), + url: URL.url(provider.logout, provider.config.queryParams && provider.config.queryParams('logout')), + }); + + provider.storage.clear(); + provider.sync(); + provider.emit('logout'); + this.logger.info('[logout]: logout complete'); + } catch (error) { + provider.emit('logout', error); + throw error; + } finally { + this.storage.delete('action'); + this.storage.delete('provider'); + this.storage.delete('handler'); + } + }); } /** @@ -266,6 +243,30 @@ export class SalteAuth extends Shared { return handler; } + + private async $secure(provider: Provider, request?: Utils.Interceptors.XHR.ExtendedXMLHttpRequest | Request) { + const handler = this.handler(); + + let response: string | boolean = null; + + while (response !== true) { + response = await provider.secure(request); + + if (response === 'login') { + if (!handler.auto) { + throw new SalteAuthError({ + code: 'auto_unsupported', + message: `The default handler doesn't support automatic authentication! (${handler.$name})`, + }); + } + + await this.login({ + provider: provider.$name, + handler: handler.$name + }); + } + } + } } export interface SalteAuth { diff --git a/src/utils/dedupe.ts b/src/utils/dedupe.ts new file mode 100644 index 00000000..21a8d34d --- /dev/null +++ b/src/utils/dedupe.ts @@ -0,0 +1,30 @@ +export class Dedupe { + public static dedupe(): Dedupe.Function { + const dedupes: { + [key: string]: Promise; + } = {}; + + return (key: string, fn: () => Promise): Promise => { + if (!dedupes[key]) { + dedupes[key] = fn().then((response) => { + delete dedupes[key]; + return response; + }).catch((error: any) => { + delete dedupes[key]; + throw error; + }); + } + + return dedupes[key]; + }; + } +} + +export declare namespace Dedupe { + /** + * Prevents multiple active promises for a given key. + * @param key - The key to dedupe + * @param fn - A function that resolves to a promise. + */ + type Function = (key: string, fn: () => Promise) => Promise; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 0db8dab1..a49ea61e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,3 +8,4 @@ export { Common } from './common'; export { Events } from './events'; export { URL } from './url'; export { Logger } from './logger'; +export { Dedupe } from './dedupe'; diff --git a/test/unit/base/provider-oauth2.spec.js b/test/unit/base/provider-oauth2.spec.js index c6b1e726..8658fcab 100644 --- a/test/unit/base/provider-oauth2.spec.js +++ b/test/unit/base/provider-oauth2.spec.js @@ -99,7 +99,7 @@ describe('OAuth2Provider', () => { expect(await example.secure()).to.equal(true); }); - it(`should return a url if we need to login`, async () => { + it(`should return 'login' if we need to login`, async () => { class Example extends OAuth2Provider { get name() { return 'example'; @@ -116,12 +116,7 @@ describe('OAuth2Provider', () => { scope: 'hello' }); - expect(await example.secure()).startsWith(`${example.url('https://google.com', { - client_id: '12345', - response_type: 'token', - redirect_uri: location.origin, - scope: 'hello' - })}&state=example-state-`); + expect(await example.secure()).equals('login'); }); it('should throw an error on unknown request types', async () => { diff --git a/test/unit/base/provider-openid.spec.js b/test/unit/base/provider-openid.spec.js index 87a0558b..20c8c858 100644 --- a/test/unit/base/provider-openid.spec.js +++ b/test/unit/base/provider-openid.spec.js @@ -128,7 +128,7 @@ describe('OpenIDProvider', () => { expect(request.setRequestHeader.firstCall.args).to.deep.equal(['Authorization', 'Bearer 12345']); }); - it('should return a login url if the id token is expired', async () => { + it(`should return 'login' if we need to login`, async () => { class Example extends OpenIDProvider { get name() { return 'example'; @@ -143,18 +143,10 @@ describe('OpenIDProvider', () => { clientID: '12345' }); - const url = await example.secure(); - const params = getParams(url); - - expect(params.client_id).to.equal('12345'); - expect(params.response_type).to.equal('id_token'); - expect(params.redirect_uri).to.equal(encodeURIComponent(location.origin)); - expect(params.scope).to.equal('openid'); - expect(params.state).to.match(/^example-state-.+/); - expect(params.nonce).to.match(/^example-nonce-.+/); + expect(await example.secure()).equals('login'); }); - it('should return a login url if the access token is expired', async () => { + it('should automatically renew the access token if it has expired', async () => { class Example extends OpenIDProvider { get name() { return 'example'; diff --git a/test/unit/salte-auth.spec.js b/test/unit/salte-auth.spec.js index 14136efd..8314108d 100644 --- a/test/unit/salte-auth.spec.js +++ b/test/unit/salte-auth.spec.js @@ -5,7 +5,6 @@ import sinon from 'sinon'; import { SalteAuth, Utils, Handler } from '../../src/salte-auth'; import { OpenID } from '../../src/generic'; import { getError } from '../utils/get-error'; -import { ignoreError } from '../utils/ignore-error'; const { expect } = chai; chai.use(chaiSinon); @@ -16,18 +15,24 @@ describe('SalteAuth', () => { /** @type {OpenID} */ let openid; - let routeCallbacks, clock; + let routeCallbacks, fetchInterceptors, xhrInterceptors, clock; beforeEach(() => { localStorage.clear(); sessionStorage.clear(); clock = sinon.useFakeTimers(1000); routeCallbacks = []; + fetchInterceptors = []; + xhrInterceptors = []; sinon.stub(Utils.Events, 'route').callsFake((routeCallback) => { routeCallbacks.push(routeCallback); }); - Utils.Interceptors.Fetch.setup(true); - Utils.Interceptors.XHR.setup(true); + sinon.stub(Utils.Interceptors.Fetch, 'add').callsFake((interceptor) => { + fetchInterceptors.push(interceptor); + }); + sinon.stub(Utils.Interceptors.XHR, 'add').callsFake((interceptor) => { + xhrInterceptors.push(interceptor); + }); openid = new OpenID({ clientID: '12345', @@ -46,6 +51,10 @@ describe('SalteAuth', () => { }); class BasicHandler extends Handler { + get auto() { + return true; + } + get name() { return 'basic'; } @@ -72,9 +81,6 @@ describe('SalteAuth', () => { }); it('should register various listeners', async () => { - sinon.stub(Utils.Interceptors.Fetch, 'add'); - sinon.stub(Utils.Interceptors.XHR, 'add'); - class Custom extends Handler { get name() { return 'custom'; @@ -107,9 +113,45 @@ describe('SalteAuth', () => { }); it('should support authentication wrap up for "login" on "connected"', async () => { - sinon.stub(Utils.Interceptors.Fetch, 'add'); - sinon.stub(Utils.Interceptors.XHR, 'add'); + class Custom extends Handler { + get name() { + return 'custom'; + } + + connected({ action }) { + expect(action).to.equal('login'); + + return { state: 'hello-world' }; + } + } + + const custom = new Custom({ default: true }); + + sinon.stub(openid, 'validate'); + sinon.stub(Utils.StorageHelpers.CookieStorage.prototype, 'get').callsFake((key) => { + switch (key) { + case 'action': return 'login'; + case 'handler': return 'custom'; + case 'provider': return 'generic.openid'; + default: throw new Error(`Unknown key. (${key})`); + } + }); + + auth = new SalteAuth({ + providers: [openid], + + handlers: [custom] + }); + await new Promise((resolve) => setTimeout(resolve)); + + expect(openid.validate.callCount).to.equal(1); + expect(openid.validate).to.be.calledWith({ + state: 'hello-world' + }); + }); + + it('should prevent logging in while wrapping up authentication for "login"', async () => { class Custom extends Handler { get name() { return 'custom'; @@ -134,12 +176,14 @@ describe('SalteAuth', () => { } }); - new SalteAuth({ + auth = new SalteAuth({ providers: [openid], handlers: [custom] }); + await auth.login(openid.$name); + await new Promise((resolve) => setTimeout(resolve)); expect(openid.validate.callCount).to.equal(1); @@ -149,9 +193,6 @@ describe('SalteAuth', () => { }); it('should support authentication wrap up for "logout" on "connected"', async () => { - sinon.stub(Utils.Interceptors.Fetch, 'add'); - sinon.stub(Utils.Interceptors.XHR, 'add'); - class Custom extends Handler { get name() { return 'custom'; @@ -187,10 +228,45 @@ describe('SalteAuth', () => { expect(openid.sync.callCount).to.equal(1); }); - it('should throw an error on authentication wrap up if the action is unknown', () => { - sinon.stub(Utils.Interceptors.Fetch, 'add'); - sinon.stub(Utils.Interceptors.XHR, 'add'); + it('should prevent logging out while wrapping up authentication for "logout"', async () => { + class Custom extends Handler { + get name() { + return 'custom'; + } + connected({ action }) { + expect(action).to.equal('logout'); + } + } + + const custom = new Custom({ default: true }); + + sinon.spy(openid.storage, 'clear'); + sinon.stub(openid, 'sync'); + sinon.stub(Utils.StorageHelpers.CookieStorage.prototype, 'get').callsFake((key) => { + switch (key) { + case 'action': return 'logout'; + case 'handler': return 'custom'; + case 'provider': return 'generic.openid'; + default: throw new Error(`Unknown key. (${key})`); + } + }); + + auth = new SalteAuth({ + providers: [openid], + + handlers: [custom] + }); + + await auth.logout(openid.$name); + + await new Promise((resolve) => setTimeout(resolve)); + + expect(openid.storage.clear.callCount).to.equal(1); + expect(openid.sync.callCount).to.equal(1); + }); + + it('should throw an error on authentication wrap up if the action is unknown', () => { class Custom extends Handler { get name() { return 'custom'; @@ -257,127 +333,78 @@ describe('SalteAuth', () => { }); describe('events(route)', () => { - it(`should attempt to automatically log us in`, async () => { - // TODO: Clean this up... - const handler = auth.handler(); - handler.auto = true; - handler.open = sinon.stub().returns(Promise.resolve()); - - let count = 0; - sinon.stub(openid, 'secure').callsFake(async () => { - count++; - if (count === 1) { - return 'https://google.com'; - } else { - return true; - } - }); - sinon.stub(openid, 'validate'); - - await routeCallbacks[0](); - - expect(openid.secure.callCount).to.equal(2); - expect(openid.validate.callCount).to.equal(1); - expect(handler.open.callCount).to.equal(1); + beforeEach(() => { + sinon.stub(auth, '$secure'); }); - it(`should skip if we're already logged in`, async () => { - sinon.stub(openid, 'secure').returns(Promise.resolve(true)); - sinon.stub(openid, 'validate'); - + it(`should attempt to automatically log us in`, async () => { await routeCallbacks[0](); - expect(openid.validate.callCount).to.equal(0); - }); - - it(`should skip automatic login if the handler doesn't support it`, async () => { - sinon.stub(openid, 'validate'); - - expect(routeCallbacks.length).to.equal(1); - - const error = await getError(routeCallbacks[0]()); - - expect(error.code).to.equal('auto_unsupported'); - expect(openid.validate.callCount).to.equal(0); + expect(auth.$secure.callCount).to.equal(1); }); it(`should skip automatic login if the provider isn't secured`, async () => { openid.config.routes = false; await routeCallbacks[0](); + + expect(auth.$secure.callCount).to.equal(0); }); }); describe('interceptor(fetch)', () => { - it('should enhance fetch requests', async () => { - openid.storage.set('response-type', 'id_token'); - openid.storage.set('id-token.raw', `0.${btoa( - JSON.stringify({ - sub: '1234567890', - name: 'John Doe', - exp: Date.now() + 99999 - }) - )}.0`); - openid.storage.set('access-token.raw', '12345'); - openid.storage.set('access-token.expiration', 99999); - openid.sync(); - - const promise = new Promise((resolve) => { - Utils.Interceptors.Fetch.add((request) => { - expect(request.headers.get('Authorization')).to.equal('Bearer 12345'); - resolve(); - }) - }); + beforeEach(() => { + sinon.stub(auth, '$secure'); + }); - await Promise.all([promise, ignoreError(fetch('https://google.com'))]); + it(`should attempt to automatically log us in`, async () => { + openid.config.endpoints = ['https://google.com']; + const expectedRequest = { + url: 'https://google.com/hello/world' + }; + + await fetchInterceptors[0](expectedRequest); + + sinon.assert.callCount(auth.$secure, 1); + sinon.assert.calledWith(auth.$secure, openid, expectedRequest); }); - it(`should skip if a provider isn't secured`, async () => { + it(`should skip automatic login if the provider isn't secured`, async () => { openid.config.endpoints = []; - const promise = new Promise((resolve) => { - Utils.Interceptors.Fetch.add((request) => { - expect(request.headers.get('Authorization')).to.equal(null); - resolve(); - }) + await fetchInterceptors[0]({ + url: 'https://google.com/hello/world' }); - await Promise.all([promise, ignoreError(fetch('https://google.com'))]); + sinon.assert.callCount(auth.$secure, 0); }); }); describe('interceptor(xhr)', () => { - it('should enhance XHR requests', async () => { - sinon.stub(openid, 'secure'); - - await new Promise((resolve) => { - const request = new XMLHttpRequest(); + beforeEach(() => { + sinon.stub(auth, '$secure'); + }); - request.addEventListener('load', resolve, { passive: true }); - request.addEventListener('error', resolve, { passive: true }); + it(`should attempt to automatically log us in`, async () => { + openid.config.endpoints = ['https://google.com']; + const expectedRequest = { + $url: 'https://google.com/hello/world' + }; - request.open('GET', 'https://google.com', false); - request.send(); - }); + await xhrInterceptors[0](expectedRequest); - expect(openid.secure.callCount).to.equal(1); + sinon.assert.callCount(auth.$secure, 1); + sinon.assert.calledWith(auth.$secure, openid, expectedRequest); }); - it(`should skip if a provider isn't secured`, async () => { + it(`should skip automatic login if the provider isn't secured`, async () => { openid.config.endpoints = []; - sinon.stub(openid, 'secure'); - - await new Promise((resolve) => { - const request = new XMLHttpRequest(); - request.addEventListener('load', resolve, { passive: true }); - request.addEventListener('error', resolve, { passive: true }); - - request.open('GET', 'https://google.com', false); - request.send(); + await xhrInterceptors[0]({ + $url: 'https://google.com/hello/world' }); - expect(openid.secure.callCount).to.equal(0); + sinon.assert.callCount(auth.$secure, 0); }); }); @@ -633,4 +660,43 @@ describe('SalteAuth', () => { expect(error.code).to.equal('invalid_handler'); }); }); + + describe('function($secure)', () => { + it('should repeat until "secure" resolves to true', async () => { + sinon.stub(auth, 'login'); + sinon.stub(openid, 'secure').onCall(0).resolves(false).resolves(true); + + await auth.$secure(openid); + + sinon.assert.callCount(auth.login, 0); + sinon.assert.callCount(openid.secure, 2); + }); + + it('should initiate a login if one is requested', async () => { + sinon.stub(auth, 'login'); + sinon.stub(openid, 'secure').onCall(0).resolves('login').resolves(true); + + await auth.$secure(openid); + + sinon.assert.callCount(auth.login, 1); + sinon.assert.callCount(openid.secure, 2); + }); + + it('should throw an error if the default handler does not support automatic authentication', async () => { + sinon.stub(auth.handler(), 'auto').get(() => false); + sinon.stub(auth, 'login'); + sinon.stub(openid, 'secure').onCall(0).resolves('login').resolves(true); + + try { + await auth.$secure(openid); + + expect.fail('Expected an error to be thrown.'); + } catch (error) { + expect(error.code).equals('auto_unsupported'); + + sinon.assert.callCount(auth.login, 0); + sinon.assert.callCount(openid.secure, 1); + } + }); + }); }); diff --git a/test/unit/utils/dedupe.spec.js b/test/unit/utils/dedupe.spec.js new file mode 100644 index 00000000..81887f72 --- /dev/null +++ b/test/unit/utils/dedupe.spec.js @@ -0,0 +1,49 @@ +import { expect } from 'chai'; + +import { Dedupe } from '../../../src/utils/dedupe'; + +import { wait } from '../../utils/wait'; + +describe('Dedupe', () => { + describe('function(dedupe)', () => { + it('should dedupe multiple requests', async () => { + const dedupe = Dedupe.dedupe(); + + const promise = dedupe('my-key', async () => { + await wait(100); + + return 'hello'; + }); + + const otherPromise = dedupe('my-key', async () => { + await wait(100); + + return 'world'; + }); + + expect(promise).equals(otherPromise); + + expect(await promise).equals('hello'); + }); + + it('should clean up previous promises upon finishing', async () => { + const dedupe = Dedupe.dedupe(); + + const promise = dedupe('my-key', async () => { + await wait(100); + + return 'hello'; + }); + + expect(await promise).equals('hello'); + + const otherPromise = dedupe('my-key', async () => { + await wait(100); + + return 'world'; + }); + + expect(await otherPromise).equals('world'); + }); + }); +}); diff --git a/test/utils/wait.ts b/test/utils/wait.ts new file mode 100644 index 00000000..b3a8cf63 --- /dev/null +++ b/test/utils/wait.ts @@ -0,0 +1,3 @@ +export function wait(ms?: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}