diff --git a/.vscode/launch.json b/.vscode/launch.json index 17691aea8..0e43b183c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -27,5 +27,74 @@ }, "disableOptimisticBPs": true }, + { + "type": "node", + "request": "launch", + "protocol": "auto", + "name": "okta-react Jest", + "cwd": "${workspaceFolder}/packages/okta-react", + "args": [ + "node_modules/.bin/jest", + "--runInBand", + "--no-cache", + "${workspaceFolder}/${relativeFile}" + ], + "sourceMaps": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "env": { + + }, + "sourceMapPathOverrides": { + "webpack:///*": "/*", + }, + "disableOptimisticBPs": true + }, + { + "type": "node", + "request": "launch", + "protocol": "auto", + "name": "okta-angular Jest", + "cwd": "${workspaceFolder}/packages/okta-angular", + "args": [ + "node_modules/.bin/jest", + "--runInBand", + "--no-cache", + "${workspaceFolder}/${relativeFile}" + ], + "sourceMaps": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "env": { + + }, + "sourceMapPathOverrides": { + "webpack:///*": "/*", + }, + "disableOptimisticBPs": true + }, + { + "type": "node", + "request": "launch", + "protocol": "auto", + "name": "okta-vue Jest", + "cwd": "${workspaceFolder}/packages/okta-vue", + "args": [ + "node_modules/.bin/jest", + "--runInBand", + "--no-cache", + "${workspaceFolder}/${relativeFile}" + ], + "sourceMaps": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "env": { + + }, + "sourceMapPathOverrides": { + "webpack:///*": "/*", + }, + "disableOptimisticBPs": true + }, ] } diff --git a/packages/okta-angular/README.md b/packages/okta-angular/README.md index c5f7536e1..982a5d2f4 100644 --- a/packages/okta-angular/README.md +++ b/packages/okta-angular/README.md @@ -98,6 +98,10 @@ For PKCE flow, this should be left undefined or set to `['code']`. - [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) - [`cookie`](https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie) +- `onTokenError` *(optional)* - callback function. Handles errors emitted by the internal TokenManager. The default handler will initiate a login flow. Passing a function here will override the default handler. + +- `isAuthenticated` *(optional)* - callback function. By default, `OktaAuthService.isAuthenticated` will return true if both `getIdToken()` and `getAccessToken()` return a value. Setting a `isAuthenticated` function on the config will skip the default logic and call the supplied function instead. The function should return a Promise and resolve to either true or false. + ### `OktaAuthModule` The top-level Angular module which provides these components and services: @@ -259,6 +263,12 @@ export class MyComponent { } ``` +#### `oktaAuth.login(fromUri?, additionalParams?)` + +Calls `onAuthRequired` or redirects to Okta if `onAuthRequired` is undefined. This method accepts a `fromUri` parameter to push the user to after successful authentication, and an optional `additionalParams` object. + +For more information on `additionalParams`, see the `oktaAuth.loginRedirect`](#oktaauthloginredirectfromuriadditionalparams) method below. + #### `oktaAuth.loginRedirect(fromUri?, additionalParams?)` Performs a full page redirect to Okta based on the initial configuration. This method accepts a `fromUri` parameter to push the user to after successful authentication. @@ -311,6 +321,11 @@ Returns the stored URI and query parameters stored when the `OktaAuthGuard` and/ ## Contributing We welcome contributions to all of our open-source packages. Please see the [contribution guide](https://github.com/okta/okta-oidc-js/blob/master/CONTRIBUTING.md) to understand how to structure a contribution. +#### `oktaAuth.getTokenManager()` + +Returns the internal [TokenManager](https://github.com/okta/okta-auth-js#tokenmanager). + +## Development ### Installing dependencies for contributions We use [yarn](https://yarnpkg.com) for dependency management when developing this package: diff --git a/packages/okta-angular/src/okta/models/auth-required-function.ts b/packages/okta-angular/src/okta/models/auth-required-function.ts deleted file mode 100644 index 1c23de376..000000000 --- a/packages/okta-angular/src/okta/models/auth-required-function.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) 2017-Present, Okta, Inc. and/or its affiliates. All rights reserved. - * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") - * - * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * - * See the License for the specific language governing permissions and limitations under the License. - */ - -import { Router } from '@angular/router'; - -import { OktaAuthService } from '../services/okta.service'; - -export type AuthRequiredFunction = (oktaAuth: OktaAuthService, router: Router) => void; diff --git a/packages/okta-angular/src/okta/models/okta.config.ts b/packages/okta-angular/src/okta/models/okta.config.ts index 34492b5e8..d4ad3be59 100644 --- a/packages/okta-angular/src/okta/models/okta.config.ts +++ b/packages/okta-angular/src/okta/models/okta.config.ts @@ -12,7 +12,13 @@ import { InjectionToken } from '@angular/core'; -import { AuthRequiredFunction } from './auth-required-function'; +import { Router } from '@angular/router'; + +import { OktaAuthService } from '../services/okta.service'; + +export type AuthRequiredFunction = (oktaAuth: OktaAuthService, router: Router) => void; +export type IsAuthenticatedFunction = () => Promise; +export type onSessionEndFunction = () => void; export interface TestingObject { disableHttpsCheck: boolean; @@ -35,6 +41,8 @@ export interface OktaConfig { onAuthRequired?: AuthRequiredFunction; testing?: TestingObject; tokenManager?: TokenManagerConfig; + isAuthenticated?: IsAuthenticatedFunction; + onSessionEnd?: onSessionEndFunction; } export const OKTA_CONFIG = new InjectionToken('okta.config.angular'); diff --git a/packages/okta-angular/src/okta/models/token-manager.ts b/packages/okta-angular/src/okta/models/token-manager.ts new file mode 100644 index 000000000..093d90205 --- /dev/null +++ b/packages/okta-angular/src/okta/models/token-manager.ts @@ -0,0 +1,3 @@ +export interface TokenManager { + on: Function; +} diff --git a/packages/okta-angular/src/okta/okta.guard.ts b/packages/okta-angular/src/okta/okta.guard.ts index 3743f7c6b..fafdee4af 100644 --- a/packages/okta-angular/src/okta/okta.guard.ts +++ b/packages/okta-angular/src/okta/okta.guard.ts @@ -19,7 +19,7 @@ import { } from '@angular/router'; import { OktaAuthService } from './services/okta.service'; -import { AuthRequiredFunction } from './models/auth-required-function'; +import { AuthRequiredFunction } from './models/okta.config'; @Injectable() export class OktaAuthGuard implements CanActivate { diff --git a/packages/okta-angular/src/okta/services/okta.service.ts b/packages/okta-angular/src/okta/services/okta.service.ts index d9d8cbb9e..75dca6112 100644 --- a/packages/okta-angular/src/okta/services/okta.service.ts +++ b/packages/okta-angular/src/okta/services/okta.service.ts @@ -19,7 +19,7 @@ import { buildConfigObject } from '@okta/configuration-validation'; -import { OKTA_CONFIG, OktaConfig } from '../models/okta.config'; +import { OKTA_CONFIG, OktaConfig, AuthRequiredFunction } from '../models/okta.config'; import { UserClaims } from '../models/user-claims'; import packageInfo from '../packageInfo'; @@ -29,6 +29,7 @@ import packageInfo from '../packageInfo'; */ import OktaAuth from '@okta/okta-auth-js'; import { Observable, Observer } from 'rxjs'; +import { TokenManager } from '../models/token-manager'; @Injectable() export class OktaAuthService { @@ -46,6 +47,12 @@ export class OktaAuthService { this.config = buildConfigObject(auth); // use normalized config object this.config.scopes = this.config.scopes || []; + // Automatically enters login flow if token renew fails. + // The default behavior can be overriden by passing a function via config: `config.onAuthRequired` + if (!this.config.onSessionEnd) { + this.config.onSessionEnd = this._onSessionEnd.bind(this); + } + /** * Scrub scopes to ensure 'openid' is included */ @@ -62,10 +69,23 @@ export class OktaAuthService { this.$authenticationState = new Observable((observer: Observer) => { this.observers.push(observer); }); } + _onSessionEnd() { + this.loginRedirect(this.router.url); + } + + getTokenManager(): TokenManager { + return this.oktaAuth.tokenManager; + } + /** * Checks if there is an access token and id token */ async isAuthenticated(): Promise { + // Support a user-provided method to check authentication + if (this.config.isAuthenticated) { + return (this.config.isAuthenticated)(); + } + const accessToken = await this.getAccessToken(); const idToken = await this.getIdToken(); return !!(accessToken || idToken); @@ -147,7 +167,7 @@ export class OktaAuthService { || this.config.responseType || ['id_token', 'token']; - this.oktaAuth.token.getWithRedirect(params); + return this.oktaAuth.token.getWithRedirect(params); // can throw } /** diff --git a/packages/okta-angular/test/e2e/harness/src/app/app.component.ts b/packages/okta-angular/test/e2e/harness/src/app/app.component.ts index f94ae2f38..9c7a58e17 100644 --- a/packages/okta-angular/test/e2e/harness/src/app/app.component.ts +++ b/packages/okta-angular/test/e2e/harness/src/app/app.component.ts @@ -18,10 +18,11 @@ import { OktaAuthService } from '@okta/okta-angular'; selector: 'app-root', template: ` - + - - + + `, diff --git a/packages/okta-angular/test/spec/guard.test.ts b/packages/okta-angular/test/spec/guard.test.ts new file mode 100644 index 000000000..a908ff705 --- /dev/null +++ b/packages/okta-angular/test/spec/guard.test.ts @@ -0,0 +1,128 @@ +jest.mock('@okta/okta-auth-js'); + +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import OktaAuth from '@okta/okta-auth-js'; + +import { + OktaAuthModule, + OktaAuthService, + OktaAuthGuard, +} from '../../src'; +import { ActivatedRouteSnapshot, RouterStateSnapshot, Router, RouterState } from '@angular/router'; + +const VALID_CONFIG = { + clientId: 'foo', + issuer: 'https://foo', + redirectUri: 'https://foo' +}; + +function createService(options) { + options = options || {}; + + const oktaAuth = options.oktaAuth || {}; + oktaAuth.tokenManager = oktaAuth.tokenManager || { on: jest.fn() }; + OktaAuth.mockImplementation(() => oktaAuth); + + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([{ path: 'foo', redirectTo: '/foo' }]), + OktaAuthModule.initAuth(VALID_CONFIG) + ], + providers: [OktaAuthService], + }); + const service = TestBed.get(OktaAuthService); + service.getTokenManager = jest.fn().mockReturnValue({ on: jest.fn() }); + service.isAuthenticated = jest.fn().mockReturnValue(Promise.resolve(options.isAuthenticated)); + service.setFromUri = jest.fn(); + service.loginRedirect = jest.fn(); + return service; +} + +describe('Angular auth guard', () => { + + beforeEach(() => { + OktaAuth.mockClear(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('canActivate', () => { + describe('isAuthenticated() = true', () => { + it('returns true', async () => { + const service = createService({ isAuthenticated: true }); + const guard = new OktaAuthGuard(service, null); + const res = await guard.canActivate(null, null); + expect(res).toBe(true); + }); + }); + + describe('isAuthenticated() = false', () => { + let service: OktaAuthService; + let guard: OktaAuthGuard; + let state: RouterStateSnapshot; + let route: ActivatedRouteSnapshot; + let router; + beforeEach(() => { + service = createService({ isAuthenticated: false }); + router = TestBed.get(Router); + guard = new OktaAuthGuard(service, router); + const routerState: RouterState = router.routerState; + state = routerState.snapshot; + route = state.root; + }); + + it('returns false', async () => { + const config = service.getOktaConfig(); + const res = await guard.canActivate(route, state); + expect(res).toBe(false); + }); + + it('by default, calls "loginRedirect()"', async () => { + const config = service.getOktaConfig(); + const res = await guard.canActivate(route, state); + expect(service.loginRedirect).toHaveBeenCalled(); + }); + + it('calls "setFromUri" with baseUrl and query object', async () => { + const baseUrl = 'http://fake.url/path'; + const query = '?query=foo&bar=baz'; + const hash = '#hash=foo'; + state.url = `${baseUrl}${query}${hash}`; + const queryObj = { 'bar': 'baz' }; + route.queryParams = queryObj; + const res = await guard.canActivate(route, state); + expect(service.setFromUri).toHaveBeenCalledWith(baseUrl, queryObj); + }); + + it('onAuthRequired can be set on route', async () => { + const fn = route.data['onAuthRequired'] = jest.fn(); + const res = await guard.canActivate(route, state); + expect(fn).toHaveBeenCalledWith(service, router); + }); + + it('onAuthRequired can be set on config', async () => { + const config = service.getOktaConfig(); + const fn = config.onAuthRequired = jest.fn(); + + const res = await guard.canActivate(route, state); + expect(fn).toHaveBeenCalledWith(service, router); + }); + }); + }); + + it('Can create the guard via angular injection', () => { + TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([{ path: 'foo', redirectTo: '/foo' }]), + OktaAuthModule.initAuth(VALID_CONFIG) + ], + providers: [OktaAuthService, OktaAuthGuard], + }); + const guard = TestBed.get(OktaAuthGuard); + expect(guard.oktaAuth).toBeTruthy(); + expect(guard.router).toBeTruthy(); + expect(typeof guard.canActivate).toBe('function'); + }); +}); diff --git a/packages/okta-angular/test/spec/service.test.ts b/packages/okta-angular/test/spec/service.test.ts index cb17626ae..d55ba3c01 100644 --- a/packages/okta-angular/test/spec/service.test.ts +++ b/packages/okta-angular/test/spec/service.test.ts @@ -1,8 +1,7 @@ jest.mock('@okta/okta-auth-js'); -import { Router } from '@angular/router'; -import { TestBed, async, ComponentFixture } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import OktaAuth from '@okta/okta-auth-js'; @@ -11,7 +10,6 @@ import PACKAGE_JSON from '../../package.json'; import { OktaAuthModule, OktaAuthService, - OKTA_CONFIG } from '../../src'; const VALID_CONFIG = { @@ -21,15 +19,38 @@ const VALID_CONFIG = { }; describe('Angular service', () => { + let _mockAuthJS; + beforeEach(() => { OktaAuth.mockClear(); + _mockAuthJS = { + tokenManager: { + on: jest.fn() + } + }; }); afterEach(() => { jest.restoreAllMocks(); }); - + + function extendMockAuthJS(mockAuthJS) { + mockAuthJS = mockAuthJS || {}; + mockAuthJS.tokenManager = Object.assign({}, mockAuthJS.tokenManager, { + on: jest.fn() + }); + mockAuthJS.token = Object.assign({}, mockAuthJS.token, { + getWithRedirect: jest.fn() + }); + return mockAuthJS; + } + + function extendConfig(config) { + return Object.assign({}, VALID_CONFIG, config); + } + describe('configuration', () => { const createInstance = (params = {}) => { + OktaAuth.mockImplementation(() => _mockAuthJS); return () => new OktaAuthService(params, undefined); }; it('should throw if no issuer is provided', () => { @@ -87,7 +108,7 @@ describe('Angular service', () => { }); it('will add "openid" scope if not present', () => { - var config = createInstance(VALID_CONFIG)().getOktaConfig(); + const config = createInstance(VALID_CONFIG)().getOktaConfig(); expect(config.scopes).toMatchInlineSnapshot(` Array [ "openid", @@ -102,7 +123,7 @@ describe('Angular service', () => { }); it('Adds a user agent on internal oktaAuth instance', () => { - var service = new OktaAuthService(VALID_CONFIG, undefined); + const service = new OktaAuthService(VALID_CONFIG, undefined); expect(service['oktaAuth'].userAgent.indexOf(`@okta/okta-angular/${PACKAGE_JSON.version}`)).toBeGreaterThan(-1); }); @@ -130,23 +151,76 @@ describe('Angular service', () => { }); describe('service methods', () => { - function createService(config=null) { + function createService(config = null, mockAuthJS = null) { + OktaAuth.mockImplementation(() => extendMockAuthJS(mockAuthJS)); + config = extendConfig(config); TestBed.configureTestingModule({ imports: [ RouterTestingModule.withRoutes([{ path: 'foo', redirectTo: '/foo' }]), - OktaAuthModule.initAuth(config || VALID_CONFIG) + OktaAuthModule.initAuth(config) ], providers: [OktaAuthService], }); return TestBed.get(OktaAuthService); } + describe('TokenManager', () => { + it('Exposes the token manager', () => { + const service = createService(); + const val = service.getTokenManager(); + expect(val).toBeTruthy(); + expect(val).toBe(service.oktaAuth.tokenManager); + }); + + it('Listens to errors from token manager', () => { + jest.spyOn(OktaAuthService.prototype, '_onTokenError').mockReturnValue(null); + const service = createService(); + const val = service.getTokenManager(); + expect(val.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('_onTokenError: calls login for error code "login_required"', () => { + jest.spyOn(OktaAuthService.prototype, 'login').mockReturnValue(null); + const service = createService(); + service._onTokenError({ errorCode: 'login_required'}); + expect(OktaAuthService.prototype.login).toHaveBeenCalled(); + }); + + it('_onTokenError: ignores other errors', () => { + jest.spyOn(OktaAuthService.prototype, 'login').mockReturnValue(null); + const service = createService(); + service._onTokenError({ errorCode: 'something'}); + expect(OktaAuthService.prototype.login).not.toHaveBeenCalled(); + }); + + it('Accepts custom function "onTokenError" via config', () => { + const onTokenError = jest.fn(); + const error = { errorCode: 'some_error' }; + const service = createService({ onTokenError }); + const val = service.getTokenManager(); + expect(val.on).toHaveBeenCalledWith('error', onTokenError); + }); + }); + describe('isAuthenticated', () => { + it('Will call a custom function if "config.isAuthenticated" was set', async () => { + jest.spyOn(OktaAuthService.prototype, 'getAccessToken').mockReturnValue(Promise.resolve(null)); + jest.spyOn(OktaAuthService.prototype, 'getIdToken').mockReturnValue(Promise.resolve(null)); + + const isAuthenticated = jest.fn().mockReturnValue(Promise.resolve('foo')); + const service = createService({ isAuthenticated }); + const ret = await service.isAuthenticated(); + expect(ret).toBe('foo'); + expect(isAuthenticated).toHaveBeenCalled(); + expect(OktaAuthService.prototype.getAccessToken).not.toHaveBeenCalled(); + expect(OktaAuthService.prototype.getIdToken).not.toHaveBeenCalled(); + }); + it('returns false if no access or id token', async () => { jest.spyOn(OktaAuthService.prototype, 'getAccessToken').mockReturnValue(Promise.resolve(null)); jest.spyOn(OktaAuthService.prototype, 'getIdToken').mockReturnValue(Promise.resolve(null)); - + const service = createService(); const val = await service.isAuthenticated(); expect(val).toBe(false); @@ -156,16 +230,16 @@ describe('Angular service', () => { it('returns true if access token', async () => { jest.spyOn(OktaAuthService.prototype, 'getAccessToken').mockReturnValue(Promise.resolve('something')); jest.spyOn(OktaAuthService.prototype, 'getIdToken').mockReturnValue(Promise.resolve(null)); - + const service = createService(); const val = await service.isAuthenticated(); expect(val).toBe(true); }); - + it('returns true if id token', async () => { jest.spyOn(OktaAuthService.prototype, 'getAccessToken').mockReturnValue(Promise.resolve(null)); jest.spyOn(OktaAuthService.prototype, 'getIdToken').mockReturnValue(Promise.resolve('something')); - + const service = createService(); const val = await service.isAuthenticated(); expect(val).toBe(true); @@ -177,36 +251,31 @@ describe('Angular service', () => { const mockToken = { accessToken: 'foo' }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ tokenManager: { get: jest.fn().mockImplementation(key => { expect(key).toBe('accessToken'); return Promise.resolve(mockToken); }) } - }; - OktaAuth.mockImplementation(() => mockAuthJS); - - const service = createService(); + }); + + const service = createService(null, mockAuthJS); const retVal = await service.getAccessToken(); expect(retVal).toBe(mockToken.accessToken); }); it('catches exceptions', async () => { - const mockToken = { - accessToken: 'foo' - }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ tokenManager: { get: jest.fn().mockImplementation(key => { expect(key).toBe('accessToken'); throw new Error('expected test error'); }) } - }; - OktaAuth.mockImplementation(() => mockAuthJS); - - const service = createService(); + }); + + const service = createService(null, mockAuthJS); const retVal = await service.getAccessToken(); expect(retVal).toBe(undefined); }); @@ -217,36 +286,29 @@ describe('Angular service', () => { const mockToken = { idToken: 'foo' }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ tokenManager: { get: jest.fn().mockImplementation(key => { expect(key).toBe('idToken'); return Promise.resolve(mockToken); }) } - }; - OktaAuth.mockImplementation(() => mockAuthJS); - - const service = createService(); + }); + const service = createService(null, mockAuthJS); const retVal = await service.getIdToken(); expect(retVal).toBe(mockToken.idToken); }); it('catches exceptions', async () => { - const mockToken = { - idToken: 'foo' - }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ tokenManager: { get: jest.fn().mockImplementation(key => { expect(key).toBe('idToken'); throw new Error('expected test error'); }) } - }; - OktaAuth.mockImplementation(() => mockAuthJS); - - const service = createService(); + }); + const service = createService(null, mockAuthJS); const retVal = await service.getIdToken(); expect(retVal).toBe(undefined); }); @@ -254,16 +316,14 @@ describe('Angular service', () => { describe('getUser', () => { it('neither id nor access token = returns undefined', async () => { - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ tokenManager: { get: jest.fn().mockImplementation(key => { return Promise.resolve(null); }) } - }; - OktaAuth.mockImplementation(() => mockAuthJS); - - const service = createService(); + }); + const service = createService(null, mockAuthJS); const retVal = await service.getUser(); expect(retVal).toBe(undefined); }); @@ -274,7 +334,7 @@ describe('Angular service', () => { idToken: 'foo', claims: 'baz', }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ tokenManager: { get: jest.fn().mockImplementation(key => { if (key === 'idToken') { @@ -283,10 +343,9 @@ describe('Angular service', () => { return Promise.resolve(null); }) } - }; - OktaAuth.mockImplementation(() => mockAuthJS); + }); - const service = createService(); + const service = createService(null, mockAuthJS); const retVal = await service.getUser(); expect(retVal).toBe(mockToken.claims); }); @@ -300,7 +359,7 @@ describe('Angular service', () => { const userInfo = { sub: 'test-sub', }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ token: { getUserInfo: jest.fn().mockReturnValue(Promise.resolve(userInfo)), }, @@ -312,10 +371,9 @@ describe('Angular service', () => { return Promise.resolve(null); }) } - }; - OktaAuth.mockImplementation(() => mockAuthJS); + }); - const service = createService(); + const service = createService(null, mockAuthJS); const retVal = await service.getUser(); expect(retVal).toBe(userInfo); }); @@ -329,7 +387,7 @@ describe('Angular service', () => { const userInfo = { sub: 'test-sub-other', }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ token: { getUserInfo: jest.fn().mockReturnValue(Promise.resolve(userInfo)), }, @@ -341,10 +399,9 @@ describe('Angular service', () => { return Promise.resolve(null); }) } - }; - OktaAuth.mockImplementation(() => mockAuthJS); + }); - const service = createService(); + const service = createService(null, mockAuthJS); const retVal = await service.getUser(); expect(mockAuthJS.token.getUserInfo).toHaveBeenCalled(); expect(retVal).toBe(mockToken.claims); @@ -380,15 +437,78 @@ describe('Angular service', () => { }); }); - describe('loginRedirect', () => { + describe('setFromUri', () => { + it('Saves the "referrerPath" in localStorage', () => { + localStorage.setItem('referrerPath', ''); + expect(localStorage.getItem('referrerPath')).toBe(''); + const service = createService(); + const uri = 'https://foo.random'; + service.setFromUri(uri); + const val = JSON.parse(localStorage.getItem('referrerPath')); + expect(val.uri).toBe(uri); + }); + }); + + describe('getFromUri', () => { + test('cleares referrer from localStorage', () => { + const TEST_VALUE = 'foo-bar'; + localStorage.setItem('referrerPath', JSON.stringify({ uri: TEST_VALUE })); + const service = createService(); + const res = service.getFromUri(); + expect(res.uri).toBe(TEST_VALUE); + expect(localStorage.getItem('referrerPath')).not.toBeTruthy(); + }); + }); + + describe('login', () => { + const expectedRes = 'sometestresult'; beforeEach(() => { - const mockAuthJS = { - token: { - getWithRedirect: jest.fn() - }, - }; - OktaAuth.mockImplementation(() => mockAuthJS); - }) + jest.spyOn(OktaAuthService.prototype, 'loginRedirect').mockReturnValue(expectedRes); + }); + it('calls loginRedirect by default', () => { + const service = createService(); + const res = service.login(); + expect(res).toBe(expectedRes); + expect(OktaAuthService.prototype.loginRedirect).toHaveBeenCalled(); + }); + + it('calls onAuthRequired, if provided, instead of loginRedirect', () => { + const onAuthRequired = jest.fn().mockReturnValue(expectedRes); + const service = createService({ onAuthRequired }); + const res = service.login(); + expect(res).toBe(expectedRes); + expect(OktaAuthService.prototype.loginRedirect).not.toHaveBeenCalled(); + expect(onAuthRequired).toHaveBeenCalledWith(service, service.router); + }); + + it('Calls setFromUri with fromUri, if provided', () => { + jest.spyOn(OktaAuthService.prototype, 'setFromUri').mockReturnValue(null); + const fromUri = 'notrandom'; + const service = createService(); + service.login(fromUri); + expect(OktaAuthService.prototype.setFromUri).toHaveBeenCalledWith(fromUri); + }); + + it('Calls setFromUri with window.location.pathname, by default', () => { + jest.spyOn(OktaAuthService.prototype, 'setFromUri').mockReturnValue(null); + const service = createService(); + service.login(); + expect(OktaAuthService.prototype.setFromUri).toHaveBeenCalledWith(window.location.pathname); + }); + + it('Passes "fromUri" and "additionalParams" to loginRedirect', () => { + jest.spyOn(OktaAuthService.prototype, 'loginRedirect').mockReturnValue(null); + const service = createService(); + const fromUri = 'https://foo.random'; + const additionalParams = { foo: 'bar', baz: 'biz' }; + service.login(fromUri, additionalParams); + expect(OktaAuthService.prototype.loginRedirect).toHaveBeenCalledWith(fromUri, additionalParams); + }); + + }); + + describe('loginRedirect', () => { + it('Saves the "referrerPath" in localStorage', async () => { localStorage.setItem('referrerPath', ''); expect(localStorage.getItem('referrerPath')).toBe(''); @@ -430,7 +550,7 @@ describe('Angular service', () => { scopes: ['foo', 'bar', 'openid'], responseType: ['unknown'], } - const service = createService(Object.assign({}, VALID_CONFIG, params)); + const service = createService(extendConfig(params)); const uri = 'https://foo.random'; await service.loginRedirect(uri); @@ -443,7 +563,7 @@ describe('Angular service', () => { scopes: ['foo', 'bar', 'openid'], responseType: ['unknown'], } - const service = createService(Object.assign({}, VALID_CONFIG, params1)); + const service = createService(extendConfig(params1)); const uri = 'https://foo.random'; const params2 = { scopes: ['something', 'different'], @@ -461,16 +581,15 @@ describe('Angular service', () => { beforeEach(() => { tokens = []; isAuthenticated = false; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ token: { parseFromUrl: jest.fn().mockImplementation(() => tokens) }, tokenManager: { add: jest.fn() } - }; - OktaAuth.mockImplementation(() => mockAuthJS); - service = createService(); + }); + service = createService(null, mockAuthJS); jest.spyOn(service, 'isAuthenticated').mockImplementation(() => Promise.resolve(isAuthenticated)); jest.spyOn(service.router, 'navigate').mockReturnValue(null); jest.spyOn(service, 'emitAuthenticationState'); @@ -485,12 +604,12 @@ describe('Angular service', () => { const accessToken = { accessToken: 'foo' }; const idToken = { idToken: 'bar' }; tokens = [accessToken, idToken]; - + await service.handleAuthentication(); expect(service.oktaAuth.tokenManager.add).toHaveBeenNthCalledWith(1, 'accessToken', accessToken); expect(service.oktaAuth.tokenManager.add).toHaveBeenNthCalledWith(2, 'idToken', idToken); }); - + it('isAuthenticated (false): does not authenticated state', async () => { isAuthenticated = false; await service.handleAuthentication(); @@ -507,21 +626,20 @@ describe('Angular service', () => { const uri = 'https://fake.test.foo'; service.setFromUri(uri); await service.handleAuthentication(); - expect(service.router.navigate).toHaveBeenCalledWith([uri], { queryParams: undefined }) + expect(service.router.navigate).toHaveBeenCalledWith([uri], { queryParams: undefined }); }); }); describe('logout', () => { let service; beforeEach(() => { - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ signOut: jest.fn(), tokenManager: { clear: jest.fn(), } - }; - OktaAuth.mockImplementation(() => mockAuthJS); - service = createService(); + }); + service = createService(null, mockAuthJS); jest.spyOn(service.router, 'navigate').mockReturnValue(null); jest.spyOn(service, 'emitAuthenticationState'); }); @@ -553,4 +671,4 @@ describe('Angular service', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/packages/okta-react/README.md b/packages/okta-react/README.md index 6f417342e..f889a16e4 100644 --- a/packages/okta-react/README.md +++ b/packages/okta-react/README.md @@ -206,6 +206,10 @@ For PKCE flow, this should be left undefined or set to `['code']`. - [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) - [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) - [`cookie`](https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie) +- **onTokenError** *(optional)* - callback function. If there is an error while renewing a token, the error will be passed to a handler function. The default handler calls `login()` to initiate a login flow. Passing a function here will override the default handler. +- **onTokenError** *(optional)* - callback function. Handles errors emitted by the internal TokenManager. The default handler will initiate a login flow. Passing a function here will override the default handler. + +- **isAuthenticated** *(optional)* - callback function. By default, `auth.isAuthenticated()` will return true if both `getIdToken()` and `getAccessToken()` return a value. Setting a `isAuthenticated` function on the config will skip the default logic and call the supplied function instead. The function should return a Promise and resolve to either true or false. #### Example @@ -388,8 +392,21 @@ auth.redirect({ Parses tokens from the url and stores them. +#### `auth.setFromUri(uri, queryParams)` + +Store the current URL state before a redirect occurs. + +#### `auth.getFromUri()` + +Returns the stored URI and query parameters stored by `setFromUri` + ## Contributing We welcome contributions to all of our open-source packages. Please see the [contribution guide](https://github.com/okta/okta-oidc-js/blob/master/CONTRIBUTING.md) to understand how to structure a contribution. +#### `auth.getTokenManager()` + +Returns the internal [TokenManager](https://github.com/okta/okta-auth-js#tokenmanager). + +## Development ### Installing dependencies for contributions We use [yarn](https://yarnpkg.com) for dependency management when developing this package: diff --git a/packages/okta-react/package.json b/packages/okta-react/package.json index d76191d33..7ab3783f2 100644 --- a/packages/okta-react/package.json +++ b/packages/okta-react/package.json @@ -12,7 +12,7 @@ "start": "yarn --cwd test/e2e/harness start", "test": "yarn lint && yarn test:unit && yarn test:e2e", "test:e2e": "yarn --cwd test/e2e/harness test", - "test:unit": "jest test/jest/" + "test:unit": "jest" }, "repository": { "type": "git", @@ -73,6 +73,9 @@ "webdriver-manager": "^12.1.4" }, "jest": { + "roots": [ + "./test/jest" + ], "setupFiles": [ "./test/jest/setup.js" ], diff --git a/packages/okta-react/src/Auth.js b/packages/okta-react/src/Auth.js index 79ef46913..9ea33835c 100644 --- a/packages/okta-react/src/Auth.js +++ b/packages/okta-react/src/Auth.js @@ -33,6 +33,13 @@ export default class Auth { assertIssuer(authConfig.issuer, testing); assertClientId(authConfig.clientId); assertRedirectUri(authConfig.redirectUri); + + // Automatically enters login flow if token renew fails. + // The default behavior can be overriden by passing a function via config: `config.onSessionEnd` + if (!authConfig.onSessionEnd) { + authConfig.onSessionEnd = this.login.bind(this); + } + this._oktaAuth = new OktaAuth(authConfig); this._oktaAuth.userAgent = `${packageInfo.name}/${packageInfo.version} ${this._oktaAuth.userAgent}`; this._config = authConfig; // use normalized config @@ -48,6 +55,10 @@ export default class Auth { this.redirect = this.redirect.bind(this); } + getTokenManager() { + return this._oktaAuth.tokenManager; + } + async handleAuthentication() { let tokens = await this._oktaAuth.token.parseFromUrl(); tokens = Array.isArray(tokens) ? tokens : [tokens]; @@ -61,8 +72,15 @@ export default class Auth { } async isAuthenticated() { + // Support a user-provided method to check authentication + if (this._config.isAuthenticated) { + return (this._config.isAuthenticated)(); + } + // If there could be tokens in the url if (location && location.hash && containsAuthTokens.test(location.hash)) return null; + + // Return true if either the access or id token exist in client storage return !!(await this.getAccessToken()) || !!(await this.getIdToken()); } @@ -105,13 +123,8 @@ export default class Auth { } async login(fromUri, additionalParams) { - const referrerPath = fromUri - ? { pathname: fromUri } - : this._history.location; - localStorage.setItem( - 'secureRouterReferrerPath', - JSON.stringify(referrerPath) - ); + // Save the current url before redirect + this.setFromUri(fromUri); if (this._config.onAuthRequired) { const auth = this; const history = this._history; @@ -139,11 +152,24 @@ export default class Auth { || this._config.scopes || ['openid', 'email', 'profile']; - this._oktaAuth.token.getWithRedirect(params); + return this._oktaAuth.token.getWithRedirect(params); + } + + setFromUri (fromUri) { + // Use current history location if fromUri was not passed + const referrerPath = fromUri + ? { pathname: fromUri } + : this._history.location; + localStorage.setItem( + 'secureRouterReferrerPath', + JSON.stringify(referrerPath) + ); + } - // return a promise that doesn't terminate so nothing - // happens after setting window.location - /* eslint-disable-next-line no-unused-vars */ - return new Promise((resolve, reject) => {}); + getFromUri () { + const referrerKey = 'secureRouterReferrerPath'; + const location = JSON.parse(localStorage.getItem(referrerKey) || '{ "pathname": "/" }'); + localStorage.removeItem(referrerKey); + return location; } } diff --git a/packages/okta-react/src/ImplicitCallback.js b/packages/okta-react/src/ImplicitCallback.js index 99df735d9..9a907a740 100644 --- a/packages/okta-react/src/ImplicitCallback.js +++ b/packages/okta-react/src/ImplicitCallback.js @@ -35,10 +35,7 @@ export default withAuth(class ImplicitCallback extends Component { return null; } - const referrerKey = 'secureRouterReferrerPath'; - const location = JSON.parse(localStorage.getItem(referrerKey) || '{ "pathname": "/" }'); - localStorage.removeItem(referrerKey); - + const location = this.props.auth.getFromUri(); return this.state.authenticated ? :

{this.state.error}

; diff --git a/packages/okta-react/test/e2e/harness/config-overrides.js b/packages/okta-react/test/e2e/harness/config-overrides.js index dbb556e7f..6065e1a08 100644 --- a/packages/okta-react/test/e2e/harness/config-overrides.js +++ b/packages/okta-react/test/e2e/harness/config-overrides.js @@ -39,6 +39,15 @@ module.exports = { '@okta/okta-react': MAIN_ENTRY }); + config.devtool = 'source-map'; + config.module.rules.push( + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre" + } + ); + return config; }, }; diff --git a/packages/okta-react/test/e2e/harness/package.json b/packages/okta-react/test/e2e/harness/package.json index e8d182b1f..c327c3eb1 100644 --- a/packages/okta-react/test/e2e/harness/package.json +++ b/packages/okta-react/test/e2e/harness/package.json @@ -19,6 +19,7 @@ "protractor": "^5.4.2", "react-app-rewired": "^2.1.3", "rimraf": "^2.6.2", + "source-map-loader": "^0.2.4", "wait-on": "^2.0.1", "webdriver-manager": "^12.1.4" }, diff --git a/packages/okta-react/test/e2e/harness/src/Home.js b/packages/okta-react/test/e2e/harness/src/Home.js index 99ab6f937..e6449b010 100644 --- a/packages/okta-react/test/e2e/harness/src/Home.js +++ b/packages/okta-react/test/e2e/harness/src/Home.js @@ -19,12 +19,15 @@ export default withAuth(class Home extends Component { super(props); this.state = { - authenticated: null + authenticated: null, + renewMessage: '', }; this.checkAuthentication = this.checkAuthentication.bind(this); this.login = this.login.bind(this); this.logout = this.logout.bind(this); + this.renewIdToken = this.renewToken.bind(this, 'idToken'); + this.renewAccessToken = this.renewToken.bind(this, 'accessToken'); } async checkAuthentication() { @@ -42,6 +45,21 @@ export default withAuth(class Home extends Component { this.props.auth.logout('/'); } + renewToken(tokenName) { + const tokenManager = this.props.auth.getTokenManager(); + tokenManager.renew(tokenName) + .then(() => { + this.setState({ + renewMessage: `Token ${tokenName} was renewed`, + }); + }) + .catch(e => { + this.setState({ + renewMessage: `Error renewing ${tokenName}: ${e}`, + }); + }); + } + componentDidMount() { this.checkAuthentication(); } @@ -69,7 +87,11 @@ export default withAuth(class Home extends Component { Protected
Session Token Login
{button} - + { this.state.authenticated ? : null } + { this.state.authenticated ? : null } +
+ { this.state.renewMessage } +
); } diff --git a/packages/okta-react/test/e2e/harness/yarn.lock b/packages/okta-react/test/e2e/harness/yarn.lock index af921986e..72f1b89ae 100644 --- a/packages/okta-react/test/e2e/harness/yarn.lock +++ b/packages/okta-react/test/e2e/harness/yarn.lock @@ -1350,7 +1350,7 @@ async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" -async@^2.1.4: +async@^2.1.4, async@^2.5.0: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" dependencies: @@ -8496,6 +8496,14 @@ source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" +source-map-loader@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-0.2.4.tgz#c18b0dc6e23bf66f6792437557c569a11e072271" + integrity sha512-OU6UJUty+i2JDpTItnizPrlpOIBLmQbWMuBg9q5bVtnHACqw1tn9nNwqJLbv0/00JjnJb/Ee5g5WS5vrRv7zIQ== + dependencies: + async "^2.5.0" + loader-utils "^1.1.0" + source-map-resolve@^0.5.0: version "0.5.2" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" diff --git a/packages/okta-react/test/jest/auth.test.js b/packages/okta-react/test/jest/auth.test.js index c7ebd9c1f..48653254b 100644 --- a/packages/okta-react/test/jest/auth.test.js +++ b/packages/okta-react/test/jest/auth.test.js @@ -1,56 +1,100 @@ import Auth from '../../src/Auth'; import AuthJS from '@okta/okta-auth-js' +import OAuthError from '@okta/okta-auth-js/lib/errors/OAuthError'; + const pkg = require('../../package.json'); jest.mock('@okta/okta-auth-js'); -const mockAccessToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2ZXIiOj' + - 'EsImp0aSI6IkFULnJ2Ym5TNGlXdTJhRE5jYTNid1RmMEg5Z' + - 'VdjV2xsS1FlaU5ZX1ZlSW1NWkEiLCJpc3MiOiJodHRwczov' + - 'L2xib3lldHRlLnRyZXhjbG91ZC5jb20vYXMvb3JzMXJnM3p' + - '5YzhtdlZUSk8wZzciLCJhdWQiOiJodHRwczovL2xib3lldH' + - 'RlLnRyZXhjbG91ZC5jb20vYXMvb3JzMXJnM3p5YzhtdlZUS' + - 'k8wZzciLCJzdWIiOiIwMHUxcGNsYTVxWUlSRURMV0NRViIs' + - 'ImlhdCI6MTQ2ODQ2NzY0NywiZXhwIjoxNDY4NDcxMjQ3LCJ' + - 'jaWQiOiJQZjBhaWZyaFladTF2MFAxYkZGeiIsInVpZCI6Ij' + - 'AwdTFwY2xhNXFZSVJFRExXQ1FWIiwic2NwIjpbIm9wZW5pZ' + - 'CIsImVtYWlsIl19.ziKfS8IjSdOdTHCZllTDnLFdE96U9bS' + - 'IsJzI0MQ0zlnM2QiiA7nvS54k6Xy78ebnkJvmeMCctjXVKk' + - 'JOEhR6vs11qVmIgbwZ4--MqUIRU3WoFEsr0muLl039QrUa1' + - 'EQ9-Ua9rPOMaO0pFC6h2lfB_HfzGifXATKsN-wLdxk6cgA'; - -const standardAccessTokenParsed = { - accessToken: mockAccessToken, - expiresAt: new Date().getTime() + 100, // ensure token is active - scopes: ['openid', 'email'], - tokenType: 'Bearer', - authorizeUrl: 'https://foo/oauth2/v1/authorize', - userinfoUrl: 'https://foo/oauth2/v1/userinfo' -}; - -const mockAuthJsInstance = { - userAgent: 'okta-auth-js', - tokenManager: { - get: jest.fn().mockReturnValue(Promise.resolve(standardAccessTokenParsed)) - }, - token: { - getWithRedirect: jest.fn() - } -}; - -const mockAuthJsInstanceWithError = { - userAgent: 'okta-auth-js', - tokenManager: { - get: jest.fn().mockImplementation(() => { - throw new Error(); - }) - }, - token: { - getWithRedirect: jest.fn() - } -}; - -describe('Auth configuration', () => { +describe('Auth component', () => { + let mockAuthJsInstance; + let mockAuthJsInstanceWithError; + let accessTokenParsed; + let idTokenParsed; + let validConfig; + + beforeEach(() => { + validConfig = { + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }; + + accessTokenParsed = { + accessToken: 'i am a fake access token' + }; + idTokenParsed = { + idToken: 'i am a fake id token' + }; + mockAuthJsInstance = { + userAgent: 'okta-auth-js', + tokenManager: { + get: jest.fn().mockImplementation(tokenName => { + if (tokenName === 'idToken') { + return Promise.resolve(idTokenParsed); + } else if (tokenName === 'accessToken') { + return Promise.resolve(accessTokenParsed); + } else { + throw new Error('Unknown token name: ' + tokenName); + } + }), + on: jest.fn(), + }, + token: { + getWithRedirect: jest.fn() + } + }; + + mockAuthJsInstanceWithError = { + userAgent: 'okta-auth-js', + tokenManager: { + get: jest.fn().mockImplementation(() => { + throw new Error(); + }), + on: jest.fn(), + }, + token: { + getWithRedirect: jest.fn() + } + }; + + AuthJS.mockImplementation(() => { + return mockAuthJsInstance + }); + }); + + + describe('onTokenError', () => { + it('Listens to "error" event from TokenManager', () => { + new Auth(validConfig); + expect(mockAuthJsInstance.tokenManager.on).toHaveBeenCalledWith('error', expect.anything()); + }); + + it('On OAuthError: "login_required" calls login()', () => { + const expectedError = new OAuthError('login_required', 'fake error'); + let _onTokenError; + jest.spyOn(mockAuthJsInstance.tokenManager, 'on').mockImplementation((error, handler) => { + _onTokenError = handler; + }); + const auth = new Auth(validConfig); + + jest.spyOn(auth, 'login').mockReturnValue(); + _onTokenError(expectedError); + expect(auth.login).toHaveBeenCalled(); + }); + + + it('User can provide a custom handler', () => { + const onTokenError = jest.fn(); + jest.spyOn(mockAuthJsInstance.tokenManager, 'on').mockImplementation((error, handler) => { + expect(handler).toBe(onTokenError); + }); + new Auth(Object.assign(validConfig, { + onTokenError, + })); + }); + }); + it('should throw if no issuer is provided', () => { function createInstance () { return new Auth(); @@ -202,12 +246,6 @@ describe('Auth configuration', () => { }); -describe('Auth component', () => { - beforeEach(() => { - AuthJS.mockImplementation(() => { - return mockAuthJsInstance - }); - }); test('sets the right user agent on AuthJS', () => { const auth = new Auth({ @@ -225,7 +263,7 @@ describe('Auth component', () => { redirect_uri: 'foo' }); const accessToken = await auth.getAccessToken(); - expect(accessToken).toBe(mockAccessToken); + expect(accessToken).toBe(accessTokenParsed.accessToken); done(); }); test('builds the authorize request with correct params', () => { @@ -333,7 +371,7 @@ describe('Auth component', () => { client_id: 'foo', redirect_uri: 'foo' }); - auth.login({foo: 'bar'}); + auth.login('/', {foo: 'bar'}); expect(mockAuthJsInstance.token.getWithRedirect).toHaveBeenCalledWith({ responseType: ['id_token', 'token'], scopes: ['openid', 'email', 'profile'], @@ -362,4 +400,194 @@ describe('Auth component', () => { const authenticated = await auth.isAuthenticated(); expect(authenticated).toBeFalsy(); }); + + describe('setFromUri', () => { + it('Saves the fromUri as "pathname" in localStorage', () => { + localStorage.setItem('secureRouterReferrerPath', ''); + expect(localStorage.getItem('secureRouterReferrerPath')).toBe(''); + const fromUri = '/foo/random'; + const auth = new Auth(validConfig); + auth.setFromUri(fromUri); + const val = JSON.parse(localStorage.getItem('secureRouterReferrerPath')); + expect(val.pathname).toBe(fromUri); + }); + + it('Saves the history.location by default', () => { + localStorage.setItem('secureRouterReferrerPath', ''); + expect(localStorage.getItem('secureRouterReferrerPath')).toBe(''); + const auth = new Auth(validConfig); + auth._history = { location: 'test-value' }; + auth.setFromUri(); + const val = JSON.parse(localStorage.getItem('secureRouterReferrerPath')); + expect(val).toBe(auth._history.location); + }); + + }); + + describe('getFromUri', () => { + test('cleares referrer from localStorage', () => { + const TEST_VALUE = 'foo-bar'; + localStorage.setItem('secureRouterReferrerPath', JSON.stringify({ pathname: TEST_VALUE })); + const auth = new Auth(validConfig); + const res = auth.getFromUri(); + expect(res.pathname).toBe(TEST_VALUE); + expect(localStorage.getItem('referrerPath')).not.toBeTruthy(); + }); + }); + + describe('login()', () => { + it('By default, it will call redirect()', async () => { + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + jest.spyOn(auth, 'redirect'); + + await auth.login('/'); + expect(auth.redirect).toHaveBeenCalled(); + }); + + it('will call a custom method "onAuthRequired" instead of redirect()', () => { + const onAuthRequired = jest.fn(); + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + onAuthRequired, + }); + auth._history = 'foo'; + jest.spyOn(auth, 'redirect'); + + auth.login('/'); + expect(onAuthRequired).toHaveBeenCalledWith({ auth, history: auth._history }); + expect(auth.redirect).not.toHaveBeenCalled(); + }); + }); + + describe('isAuthenticated', () => { + it('Will be true if both idToken and accessToken are present', async () => { + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + jest.spyOn(auth, 'getAccessToken').mockReturnValue(Promise.resolve(accessTokenParsed)); + jest.spyOn(auth, 'getIdToken').mockReturnValue(Promise.resolve(idTokenParsed)); + + const ret = await auth.isAuthenticated(); + expect(ret).toBe(true); + }); + + it('Will be true if accessToken is present', async () => { + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + jest.spyOn(auth, 'getAccessToken').mockReturnValue(Promise.resolve(accessTokenParsed)); + jest.spyOn(auth, 'getIdToken').mockReturnValue(Promise.resolve(null)); + + const ret = await auth.isAuthenticated(); + expect(ret).toBe(true); + expect(auth.getAccessToken).toHaveBeenCalled(); + }); + + it('Will be true if idToken is present', async () => { + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + jest.spyOn(auth, 'getAccessToken').mockReturnValue(Promise.resolve(null)); + jest.spyOn(auth, 'getIdToken').mockReturnValue(Promise.resolve(idTokenParsed)); + + const ret = await auth.isAuthenticated(); + expect(ret).toBe(true); + + expect(auth.getIdToken).toHaveBeenCalled(); + }); + + it('Will be false if neither idToken nor accessToken are present', async () => { + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + jest.spyOn(auth, 'getAccessToken').mockReturnValue(Promise.resolve(null)); + jest.spyOn(auth, 'getIdToken').mockReturnValue(Promise.resolve(null)); + + const ret = await auth.isAuthenticated(); + expect(ret).toBe(false); + }); + + it('Will return null if there is idToken in the URL', async () => { + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + jest.spyOn(auth, 'getAccessToken'); + jest.spyOn(auth, 'getIdToken'); + + location.hash = 'id_token=foo'; + const ret = await auth.isAuthenticated(); + expect(ret).toBe(null); + + expect(auth.getAccessToken).not.toHaveBeenCalled(); + expect(auth.getIdToken).not.toHaveBeenCalled(); + }); + + it('Will return null if there is accesstoken in the URL', async () => { + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + jest.spyOn(auth, 'getAccessToken'); + jest.spyOn(auth, 'getIdToken'); + + location.hash = 'access_token=foo'; + const ret = await auth.isAuthenticated(); + expect(ret).toBe(null); + + expect(auth.getAccessToken).not.toHaveBeenCalled(); + expect(auth.getIdToken).not.toHaveBeenCalled(); + }); + + it('Will return null if there is code in the URL', async () => { + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + jest.spyOn(auth, 'getAccessToken'); + jest.spyOn(auth, 'getIdToken'); + + location.hash = 'code=foo'; + const ret = await auth.isAuthenticated(); + expect(ret).toBe(null); + + expect(auth.getAccessToken).not.toHaveBeenCalled(); + expect(auth.getIdToken).not.toHaveBeenCalled(); + }); + + it('Will call a custom function if "config.isAuthenticated" was set', async () => { + const isAuthenticated = jest.fn().mockReturnValue(Promise.resolve('foo')); + const auth = new Auth({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + isAuthenticated, + }); + jest.spyOn(auth, 'getAccessToken'); + jest.spyOn(auth, 'getIdToken'); + const ret = await auth.isAuthenticated(); + expect(ret).toBe('foo'); + expect(isAuthenticated).toHaveBeenCalled(); + expect(auth.getAccessToken).not.toHaveBeenCalled(); + expect(auth.getIdToken).not.toHaveBeenCalled(); + }); + }); + }); diff --git a/packages/okta-react/test/jest/secureRoute.test.js b/packages/okta-react/test/jest/secureRoute.test.js index 592ba6334..0164184e2 100644 --- a/packages/okta-react/test/jest/secureRoute.test.js +++ b/packages/okta-react/test/jest/secureRoute.test.js @@ -6,7 +6,9 @@ import Security from '../../src/Security'; describe('', () => { const mockProps = { - auth: {}, + auth: { + isAuthenticated: jest.fn() + }, }; it('should accept a "path" prop and render a component', () => { diff --git a/packages/okta-vue/README.md b/packages/okta-vue/README.md index 8fe34b741..687c0ce62 100644 --- a/packages/okta-vue/README.md +++ b/packages/okta-vue/README.md @@ -235,6 +235,12 @@ he most commonly used options are shown here. See [Configuration Reference](http - [`sessionStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage) - [`cookie`](https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie) +- `onAuthRequired` *(optional)*: Accepts a callback to make a decision when authentication is required. If not supplied, `okta-vue` will redirect directly to Okta for authentication. + +-`onTokenError` *(optional)* - callback function. If there is an error while renewing a token, the error will be passed to a handler function. The default handler calls `loginRedirect()` to initiate a login flow. Passing a function here will override the default handler. + +- `isAuthenticated` *(optional)* - callback function. By default, `$auth.isAuthenticated()` will return true if both `getIdToken()` and `getAccessToken()` return a value. Setting a `isAuthenticated` function on the config will skip the default logic and call the supplied function instead. The function should return a Promise and resolve to either true or false. + #### `$auth.loginRedirect(fromUri, additionalParams)` Performs a full page redirect to Okta based on the initial configuration. This method accepts a `fromUri` parameter to push the user to after successful authentication. @@ -269,8 +275,21 @@ Returns the result of the OpenID Connect `/userinfo` endpoint if an access token Parses the tokens returned as hash fragments in the OAuth 2.0 Redirect URI. +#### `$auth.setFromUri(uri, queryParams)` + +Store the current URL state before a redirect occurs. + +#### `$auth.getFromUri()` + +Returns the stored URI and query parameters stored by `setFromUri` + ## Contributing We welcome contributions to all of our open-source packages. Please see the [contribution guide](https://github.com/okta/okta-oidc-js/blob/master/CONTRIBUTING.md) to understand how to structure a contribution. +#### `$auth.getTokenManager` + +Returns the internal [TokenManager](https://github.com/okta/okta-auth-js#tokenmanager). + +## Development ### Installing dependencies for contributions We use [yarn](https://yarnpkg.com) for dependency management when developing this package: diff --git a/packages/okta-vue/src/Auth.js b/packages/okta-vue/src/Auth.js index aa67c0528..2d519f379 100644 --- a/packages/okta-vue/src/Auth.js +++ b/packages/okta-vue/src/Auth.js @@ -8,85 +8,133 @@ import AuthJS from '@okta/okta-auth-js' import packageInfo from './packageInfo' import ImplicitCallback from './components/ImplicitCallback' -function install (Vue, options) { - const authConfig = initConfig(options) - const oktaAuth = new AuthJS(authConfig) - oktaAuth.userAgent = `${packageInfo.name}/${packageInfo.version} ${oktaAuth.userAgent}` - - Vue.prototype.$auth = { - async loginRedirect (fromUri, additionalParams) { - if (fromUri) { - localStorage.setItem('referrerPath', fromUri) - } - let params = buildConfigObject(additionalParams) - params.scopes = params.scopes || authConfig.scopes - params.responseType = params.responseType || authConfig.responseType - return oktaAuth.token.getWithRedirect(params) - }, - async logout () { - oktaAuth.tokenManager.clear() - await oktaAuth.signOut() - }, - async isAuthenticated () { - return !!(await this.getAccessToken()) || !!(await this.getIdToken()) - }, - async handleAuthentication () { - const tokens = await oktaAuth.token.parseFromUrl() - tokens.forEach(token => { - if (token.accessToken) oktaAuth.tokenManager.add('accessToken', token) - if (token.idToken) oktaAuth.tokenManager.add('idToken', token) - }) - }, - getFromUri () { - const path = localStorage.getItem('referrerPath') || '/' - localStorage.removeItem('referrerPath') - return path - }, - async getIdToken () { - try { - const idToken = await oktaAuth.tokenManager.get('idToken') - return idToken.idToken - } catch (err) { - // The user no longer has an existing SSO session in the browser. - // (OIDC error `login_required`) - // Ask the user to authenticate again. - return undefined - } - }, - async getAccessToken () { - try { - const accessToken = await oktaAuth.tokenManager.get('accessToken') - return accessToken.accessToken - } catch (err) { - // The user no longer has an existing SSO session in the browser. - // (OIDC error `login_required`) - // Ask the user to authenticate again. - return undefined - } - }, - async getUser () { - const accessToken = await oktaAuth.tokenManager.get('accessToken') - const idToken = await oktaAuth.tokenManager.get('idToken') - if (accessToken && idToken) { - const userinfo = await oktaAuth.token.getUserInfo(accessToken) - if (userinfo.sub === idToken.claims.sub) { - // Only return the userinfo response if subjects match to - // mitigate token substitution attacks - return userinfo - } +class Auth { + constructor (options) { + this.config = initConfig(options) + this.oktaAuth = new AuthJS(this.config) + this.oktaAuth.userAgent = `${packageInfo.name}/${packageInfo.version} ${this.oktaAuth.userAgent}` + + // Automatically enters login flow if token renew fails. + // The default behavior can be overriden by passing a function via config: `config.onTokenError` + this.getTokenManager().on('error', this.config.onTokenError || this._onTokenError.bind(this)) + } + + async login (fromUri, additionalParams) { + this.setFromUri(fromUri || window.location.pathname) + + // Custom login flow + if (this.config.onAuthRequired) { + return this.config.onAuthRequired({ fromUri, additionalParams }) + } + // Default flow + return this.loginRedirect(fromUri, additionalParams) + } + + async loginRedirect (fromUri, additionalParams) { + if (fromUri) { + this.setFromUri(fromUri) + } + let params = buildConfigObject(additionalParams) + params.scopes = params.scopes || this.config.scopes + params.responseType = params.responseType || this.config.responseType + return this.oktaAuth.token.getWithRedirect(params) + } + + async logout () { + this.oktaAuth.tokenManager.clear() + await this.oktaAuth.signOut() + } + + async isAuthenticated () { + // Support a user-provided method to check authentication + if (this.config.isAuthenticated) { + return (this.config.isAuthenticated)() + } + + return !!(await this.getAccessToken()) || !!(await this.getIdToken()) + } + + async handleAuthentication () { + const tokens = await this.oktaAuth.token.parseFromUrl() + tokens.forEach(token => { + if (token.accessToken) this.oktaAuth.tokenManager.add('accessToken', token) + if (token.idToken) this.oktaAuth.tokenManager.add('idToken', token) + }) + } + + setFromUri (fromUri) { + localStorage.setItem('referrerPath', fromUri) + } + + getFromUri () { + const path = localStorage.getItem('referrerPath') || '/' + localStorage.removeItem('referrerPath') + return path + } + + async getIdToken () { + try { + const idToken = await this.oktaAuth.tokenManager.get('idToken') + return idToken.idToken + } catch (err) { + // The user no longer has an existing SSO session in the browser. + // (OIDC error `login_required`) + // Ask the user to authenticate again. + return undefined + } + } + + async getAccessToken () { + try { + const accessToken = await this.oktaAuth.tokenManager.get('accessToken') + return accessToken.accessToken + } catch (err) { + // The user no longer has an existing SSO session in the browser. + // (OIDC error `login_required`) + // Ask the user to authenticate again. + return undefined + } + } + + async getUser () { + const accessToken = await this.oktaAuth.tokenManager.get('accessToken') + const idToken = await this.oktaAuth.tokenManager.get('idToken') + if (accessToken && idToken) { + const userinfo = await this.oktaAuth.token.getUserInfo(accessToken) + if (userinfo.sub === idToken.claims.sub) { + // Only return the userinfo response if subjects match to + // mitigate token substitution attacks + return userinfo } - return idToken ? idToken.claims : undefined - }, - authRedirectGuard () { - return async (to, from, next) => { - if (to.matched.some(record => record.meta.requiresAuth) && !(await this.isAuthenticated())) { - this.loginRedirect(to.path) - } else { - next() - } + } + return idToken ? idToken.claims : undefined + } + + authRedirectGuard () { + return async (to, from, next) => { + if (to.matched.some(record => record.meta.requiresAuth) && !(await this.isAuthenticated())) { + this.login(to.path) + } else { + next() } } } + + getTokenManager () { + return this.oktaAuth.tokenManager + } + + // Handle token manager errors: Default implementation + _onTokenError (error) { + if (error.errorCode === 'login_required') { + this.login() + } + } +} + +function install (Vue, options) { + const auth = new Auth(options) + Vue.prototype.$auth = auth } function handleCallback () { return ImplicitCallback } diff --git a/packages/okta-vue/test/jest/Auth.config.spec.js b/packages/okta-vue/test/jest/Auth.config.spec.js index 9708e1d4c..ca47f95c5 100644 --- a/packages/okta-vue/test/jest/Auth.config.spec.js +++ b/packages/okta-vue/test/jest/Auth.config.spec.js @@ -5,6 +5,16 @@ import AuthJS from '@okta/okta-auth-js' jest.mock('@okta/okta-auth-js') describe('Auth configuration', () => { + beforeEach(() => { + AuthJS.mockImplementation(() => { + return { + tokenManager: { + on: jest.fn() + } + } + }) + }) + it('does not throw if config is valid', () => { const validConfig = { issuer: 'https://foo', diff --git a/packages/okta-vue/test/jest/Auth.interface.spec.js b/packages/okta-vue/test/jest/Auth.interface.spec.js index 77c7e0993..610255c7a 100644 --- a/packages/okta-vue/test/jest/Auth.interface.spec.js +++ b/packages/okta-vue/test/jest/Auth.interface.spec.js @@ -11,13 +11,24 @@ const baseConfig = { redirectUri: 'foo' } +function extendMockAuthJS (mockAuthJS) { + mockAuthJS = mockAuthJS || {} + mockAuthJS.tokenManager = Object.assign({}, mockAuthJS.tokenManager, { + on: jest.fn() + }) + mockAuthJS.token = Object.assign({}, mockAuthJS.token, { + getWithRedirect: jest.fn() + }) + return mockAuthJS +} + describe('Auth constructor', () => { let mockAuthJsInstance beforeEach(() => { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ userAgent: 'foo' - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -83,15 +94,64 @@ describe('Auth constructor', () => { }) }) +describe('login', () => { + let mockAuthJsInstance + let localVue + + function bootstrap (config) { + mockAuthJsInstance = extendMockAuthJS({}) + AuthJS.mockImplementation(() => { + return mockAuthJsInstance + }) + localVue = createLocalVue() + localVue.use(Auth, Object.assign({}, baseConfig, config)) + } + + it('Calls loginRedirect by default', () => { + const fromUri = 'https://fake' + const additionalParams = { foo: 'bar' } + bootstrap() + jest.spyOn(localVue.prototype.$auth, 'loginRedirect') + localVue.prototype.$auth.login(fromUri, additionalParams) + expect(localVue.prototype.$auth.loginRedirect).toHaveBeenCalledWith(fromUri, additionalParams) + }) + + it('Will call a custom callback "onAuthRequired" if provided', () => { + const onAuthRequired = jest.fn() + const fromUri = 'https://fake' + const additionalParams = { foo: 'bar' } + bootstrap({ onAuthRequired }) + jest.spyOn(localVue.prototype.$auth, 'loginRedirect') + localVue.prototype.$auth.login(fromUri, additionalParams) + expect(onAuthRequired).toHaveBeenCalledWith({ fromUri, additionalParams }) + expect(localVue.prototype.$auth.loginRedirect).not.toHaveBeenCalled() + }) + + it('calls setFromUri with fromUri if provided', () => { + const fromUri = 'notrandom' + jest.spyOn(localVue.prototype.$auth, 'setFromUri') + jest.spyOn(localVue.prototype.$auth, 'loginRedirect').mockReturnValue(null) + localVue.prototype.$auth.login(fromUri) + expect(localVue.prototype.$auth.setFromUri).toHaveBeenCalledWith(fromUri) + }) + + it('calls setFromUri with window.location.pathname by default', () => { + jest.spyOn(localVue.prototype.$auth, 'setFromUri') + jest.spyOn(localVue.prototype.$auth, 'loginRedirect').mockReturnValue(null) + localVue.prototype.$auth.login() + expect(localVue.prototype.$auth.setFromUri).toHaveBeenCalledWith(window.location.pathname) + }) +}) + describe('loginRedirect', () => { let mockAuthJsInstance let localVue beforeEach(() => { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ token: { getWithRedirect: jest.fn() } - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -99,6 +159,19 @@ describe('loginRedirect', () => { localVue.use(Auth, baseConfig) }) + it('calls setFromUri if fromUri is provided', () => { + const fromUri = 'notrandom' + jest.spyOn(localVue.prototype.$auth, 'setFromUri') + localVue.prototype.$auth.loginRedirect(fromUri) + expect(localVue.prototype.$auth.setFromUri).toHaveBeenCalledWith(fromUri) + }) + + it('does not call setFromUri if no fromUri is provided', () => { + jest.spyOn(localVue.prototype.$auth, 'setFromUri') + localVue.prototype.$auth.loginRedirect() + expect(localVue.prototype.$auth.setFromUri).not.toHaveBeenCalled() + }) + it('loginRedirect: calls oktaAuth.token.getWithRedirect when redirecting to Okta', () => { localVue.prototype.$auth.loginRedirect() expect(mockAuthJsInstance.token.getWithRedirect).toHaveBeenCalled() @@ -130,12 +203,12 @@ describe('logout', () => { let localVue let mockAuthJsInstance beforeEach(() => { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ signOut: jest.fn().mockReturnValue(null), tokenManager: { clear: jest.fn().mockReturnValue(Promise.resolve()) } - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -157,16 +230,16 @@ describe('isAuthenticated', () => { let mockAuthJsInstance let localVue - beforeEach(() => { - mockAuthJsInstance = {} + function bootstrap (config) { + mockAuthJsInstance = extendMockAuthJS({}) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) localVue = createLocalVue() - localVue.use(Auth, baseConfig) - }) - + localVue.use(Auth, Object.assign({}, baseConfig, config)) + } test('isAuthenticated() returns false when the TokenManager throws an error', async () => { + bootstrap() mockAuthJsInstance.tokenManager = { get: jest.fn().mockImplementation(() => { throw new Error() @@ -178,6 +251,7 @@ describe('isAuthenticated', () => { }) test('isAuthenticated() returns false when the TokenManager does not return an access token', async () => { + bootstrap() mockAuthJsInstance.tokenManager = { get: jest.fn().mockImplementation(() => { return null @@ -188,6 +262,7 @@ describe('isAuthenticated', () => { }) test('isAuthenticated() returns true when the TokenManager returns an access token', async () => { + bootstrap() mockAuthJsInstance.tokenManager = { get: jest.fn().mockReturnValue(Promise.resolve({ accessToken: 'fake' })) } @@ -195,6 +270,18 @@ describe('isAuthenticated', () => { expect(mockAuthJsInstance.tokenManager.get).toHaveBeenCalledWith('accessToken') expect(authenticated).toBeTruthy() }) + + it('Will call a custom function if "config.isAuthenticated" was set', async () => { + const isAuthenticated = jest.fn().mockReturnValue(Promise.resolve('foo')) + bootstrap({ isAuthenticated }) + jest.spyOn(localVue.prototype.$auth, 'getAccessToken') + jest.spyOn(localVue.prototype.$auth, 'getIdToken') + const ret = await localVue.prototype.$auth.isAuthenticated() + expect(ret).toBe('foo') + expect(isAuthenticated).toHaveBeenCalled() + expect(localVue.prototype.$auth.getAccessToken).not.toHaveBeenCalled() + expect(localVue.prototype.$auth.getIdToken).not.toHaveBeenCalled() + }) }) describe('handleAuthentication', () => { @@ -202,14 +289,14 @@ describe('handleAuthentication', () => { let localVue function bootstrap (tokens) { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ token: { parseFromUrl: jest.fn().mockReturnValue(Promise.resolve(tokens)) }, tokenManager: { add: jest.fn() } - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -230,6 +317,17 @@ describe('handleAuthentication', () => { }) }) +describe('setFromUri', () => { + test('sets referrer in localStorage', () => { + const TEST_VALUE = 'foo-bar' + localStorage.setItem('referrerPath', '') + const localVue = createLocalVue() + localVue.use(Auth, baseConfig) + localVue.prototype.$auth.setFromUri(TEST_VALUE) + expect(localStorage.getItem('referrerPath')).toBe(TEST_VALUE) + }) +}) + describe('getFromUri', () => { test('cleares referrer from localStorage', () => { const TEST_VALUE = 'foo-bar' @@ -247,11 +345,11 @@ describe('getAccessToken', () => { let localVue function bootstrap (token) { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ tokenManager: { get: jest.fn().mockReturnValue(Promise.resolve(token)) } - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -272,11 +370,11 @@ describe('getIdToken', () => { let localVue function bootstrap (token) { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ tokenManager: { get: jest.fn().mockReturnValue(Promise.resolve(token)) } - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -297,7 +395,7 @@ describe('getUser', () => { let localVue function bootstrap (options = {}) { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ token: { getUserInfo: jest.fn().mockReturnValue(Promise.resolve(options.userInfo)) }, @@ -310,7 +408,7 @@ describe('getUser', () => { } }) } - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -369,3 +467,105 @@ describe('getUser', () => { expect(val).toBe(claims) }) }) + +describe('TokenManager', () => { + let mockAuthJsInstance + let localVue + + function bootstrap (config) { + mockAuthJsInstance = extendMockAuthJS({}) + AuthJS.mockImplementation(() => { + return mockAuthJsInstance + }) + localVue = createLocalVue() + localVue.use(Auth, Object.assign({}, baseConfig, config)) + } + + it('Exposes the token manager', () => { + bootstrap() + const val = localVue.prototype.$auth.getTokenManager() + expect(val).toBeTruthy() + expect(val).toBe(localVue.prototype.$auth.oktaAuth.tokenManager) + }) + + it('Listens to errors from token manager', () => { + bootstrap() + const val = localVue.prototype.$auth.getTokenManager() + expect(val.on).toHaveBeenCalledWith('error', expect.any(Function)) + }) + + it('_onTokenError: calls loginRedirect for error code "login_required"', () => { + bootstrap() + jest.spyOn(localVue.prototype.$auth, 'login').mockReturnValue(null) + localVue.prototype.$auth._onTokenError({ errorCode: 'login_required' }) + expect(localVue.prototype.$auth.login).toHaveBeenCalled() + }) + + it('_onTokenError: ignores other errors', () => { + bootstrap() + jest.spyOn(localVue.prototype.$auth, 'login').mockReturnValue(null) + localVue.prototype.$auth._onTokenError({ errorCode: 'something' }) + expect(localVue.prototype.$auth.login).not.toHaveBeenCalled() + }) + + it('Accepts custom function "onTokenError" via config', () => { + const onTokenError = jest.fn() + bootstrap({ onTokenError }) + const val = localVue.prototype.$auth.getTokenManager() + expect(val.on).toHaveBeenCalledWith('error', onTokenError) + }) +}) + +describe('authRedirectGuard', () => { + let localVue + let mockAuthJsInstance + beforeEach(() => { + mockAuthJsInstance = extendMockAuthJS() + AuthJS.mockImplementation(() => { + return mockAuthJsInstance + }) + localVue = createLocalVue() + localVue.use(Auth, baseConfig) + }) + + it('does nothing if route does not requireAuth', async () => { + const route = { + meta: { + requiresAuth: false + } + } + const next = jest.fn() + jest.spyOn(localVue.prototype.$auth, 'isAuthenticated') + await localVue.prototype.$auth.authRedirectGuard()({ matched: [route] }, null, next) + expect(localVue.prototype.$auth.isAuthenticated).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + }) + + it('calls next() if authenticated', async () => { + const route = { + meta: { + requiresAuth: true + } + } + const next = jest.fn() + jest.spyOn(localVue.prototype.$auth, 'isAuthenticated').mockReturnValue(Promise.resolve(true)) + await localVue.prototype.$auth.authRedirectGuard()({ matched: [route] }, null, next) + expect(localVue.prototype.$auth.isAuthenticated).toHaveBeenCalled() + expect(next).toHaveBeenCalled() + }) + + it('calls login() if not authenticated', async () => { + const route = { + meta: { + requiresAuth: true + } + } + const next = jest.fn() + jest.spyOn(localVue.prototype.$auth, 'isAuthenticated').mockReturnValue(Promise.resolve(false)) + jest.spyOn(localVue.prototype.$auth, 'login') + await localVue.prototype.$auth.authRedirectGuard()({ matched: [route] }, null, next) + expect(localVue.prototype.$auth.isAuthenticated).toHaveBeenCalled() + expect(next).not.toHaveBeenCalled() + expect(localVue.prototype.$auth.login).toHaveBeenCalled() + }) +}) diff --git a/packages/okta-vue/test/jest/ImplicitCallback.spec.js b/packages/okta-vue/test/jest/ImplicitCallback.spec.js index 576acb340..f3ff89875 100644 --- a/packages/okta-vue/test/jest/ImplicitCallback.spec.js +++ b/packages/okta-vue/test/jest/ImplicitCallback.spec.js @@ -2,6 +2,9 @@ import { createLocalVue, mount } from '@vue/test-utils' import waitForExpect from 'wait-for-expect' import VueRouter from 'vue-router' import { default as Auth } from '../../src/Auth' +import AuthJS from '@okta/okta-auth-js' + +jest.mock('@okta/okta-auth-js') describe('ImplicitCallback', () => { const baseConfig = { @@ -13,6 +16,14 @@ describe('ImplicitCallback', () => { let localVue let wrapper function bootstrap (options = {}) { + AuthJS.mockImplementation(() => { + return { + tokenManager: { + on: jest.fn() + } + } + }) + localVue = createLocalVue() localVue.use(VueRouter) localVue.use(Auth, baseConfig)