From 43517ea6634e7ef03efbb60c35c4598655637453 Mon Sep 17 00:00:00 2001 From: Aaron Granick Date: Mon, 9 Sep 2019 15:30:39 -0700 Subject: [PATCH] feat: Expose TokenManager, handle token error events --- .vscode/launch.json | 77 +++++ packages/okta-angular/README.md | 9 + packages/okta-angular/package.json | 2 +- .../src/okta/models/auth-required-function.ts | 17 - .../src/okta/models/okta.config.ts | 10 +- .../src/okta/models/token-manager.ts | 3 + packages/okta-angular/src/okta/okta.guard.ts | 2 +- .../src/okta/services/okta.service.ts | 21 ++ packages/okta-angular/test/spec/guard.test.ts | 128 ++++++++ .../okta-angular/test/spec/service.test.ts | 202 +++++++----- packages/okta-react/README.md | 9 + packages/okta-react/package.json | 5 +- packages/okta-react/src/Auth.js | 29 +- .../okta-react/test/e2e/harness/src/Home.js | 26 +- packages/okta-react/test/jest/auth.test.js | 305 ++++++++++++++---- packages/okta-react/test/support/tokens.js | 289 +++++++++++++++++ packages/okta-vue/README.md | 9 + packages/okta-vue/src/Auth.js | 183 ++++++----- .../okta-vue/test/jest/Auth.config.spec.js | 11 + .../okta-vue/test/jest/Auth.interface.spec.js | 111 +++++-- .../test/jest/ImplicitCallback.spec.js | 11 + 21 files changed, 1203 insertions(+), 256 deletions(-) create mode 100644 .vscode/launch.json delete mode 100644 packages/okta-angular/src/okta/models/auth-required-function.ts create mode 100644 packages/okta-angular/src/okta/models/token-manager.ts create mode 100644 packages/okta-angular/test/spec/guard.test.ts create mode 100644 packages/okta-react/test/support/tokens.js diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..4660dc7d5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,77 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "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 1073a3aa3..fa773c542 100644 --- a/packages/okta-angular/README.md +++ b/packages/okta-angular/README.md @@ -94,6 +94,10 @@ For PKCE flow, this should be left undefined or set to `['code']`. - `autoRenew` *(optional)*: By default, the library will attempt to renew expired tokens. When an expired token is requested by the library, a renewal request is executed to update the token. If you wish to to disable auto renewal of tokens, set autoRenew to false. +- `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, `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: @@ -307,6 +311,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/package.json b/packages/okta-angular/package.json index 9e2392294..75c706461 100644 --- a/packages/okta-angular/package.json +++ b/packages/okta-angular/package.json @@ -16,7 +16,7 @@ "start": "yarn --cwd test/e2e/harness/ start", "test": "yarn lint && yarn test:e2e && yarn test:unit", "test:e2e": "yarn --cwd test/e2e/harness/ e2e", - "test:unit": "yarn --cwd test/e2e/harness/ test" + "test:unit": "jest" }, "repository": "https://github.com/okta/okta-oidc-js", "homepage": "https://github.com/okta/okta-oidc-js/tree/master/packages/okta-angular", 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 73fa8b824..7ec806e54 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 onTokenErrorFunction = (error: Error) => void; export interface TestingObject { disableHttpsCheck: boolean; @@ -28,6 +34,8 @@ export interface OktaConfig { pkce?: boolean; onAuthRequired?: AuthRequiredFunction; testing?: TestingObject; + isAuthenticated?: IsAuthenticatedFunction; + onTokenError?: onTokenErrorFunction; } 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..4f53f06eb 100644 --- a/packages/okta-angular/src/okta/services/okta.service.ts +++ b/packages/okta-angular/src/okta/services/okta.service.ts @@ -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 { @@ -60,12 +61,32 @@ export class OktaAuthService { this.oktaAuth = new OktaAuth(this.config); this.oktaAuth.userAgent = `${packageInfo.name}/${packageInfo.version} ${this.oktaAuth.userAgent}`; this.$authenticationState = new Observable((observer: Observer) => { this.observers.push(observer); }); + + // 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)); + } + + // Handle token manager errors: Default implementation + _onTokenError(error) { + if (error.errorCode === 'login_required') { + this.loginRedirect(); + } + } + + 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); 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 6afe33b11..59b40b825 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", @@ -97,7 +118,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); }); @@ -125,23 +146,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 loginRedirect for error code "login_required"', () => { + jest.spyOn(OktaAuthService.prototype, 'loginRedirect').mockReturnValue(null); + const service = createService(); + service._onTokenError({ errorCode: 'login_required'}); + expect(OktaAuthService.prototype.loginRedirect).toHaveBeenCalled(); + }); + + it('_onTokenError: ignores other errors', () => { + jest.spyOn(OktaAuthService.prototype, 'loginRedirect').mockReturnValue(null); + const service = createService(); + service._onTokenError({ errorCode: 'something'}); + expect(OktaAuthService.prototype.loginRedirect).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); @@ -151,16 +225,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); @@ -172,36 +246,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); }); @@ -212,36 +281,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); }); @@ -249,16 +311,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); }); @@ -269,7 +329,7 @@ describe('Angular service', () => { idToken: 'foo', claims: 'baz', }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ tokenManager: { get: jest.fn().mockImplementation(key => { if (key === 'idToken') { @@ -278,10 +338,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); }); @@ -295,7 +354,7 @@ describe('Angular service', () => { const userInfo = { sub: 'test-sub', }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ token: { getUserInfo: jest.fn().mockReturnValue(Promise.resolve(userInfo)), }, @@ -307,10 +366,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); }); @@ -324,7 +382,7 @@ describe('Angular service', () => { const userInfo = { sub: 'test-sub-other', }; - const mockAuthJS = { + const mockAuthJS = extendMockAuthJS({ token: { getUserInfo: jest.fn().mockReturnValue(Promise.resolve(userInfo)), }, @@ -336,10 +394,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); @@ -376,14 +433,7 @@ describe('Angular service', () => { }); describe('loginRedirect', () => { - beforeEach(() => { - const mockAuthJS = { - token: { - getWithRedirect: jest.fn() - }, - }; - OktaAuth.mockImplementation(() => mockAuthJS); - }) + it('Saves the "referrerPath" in localStorage', async () => { localStorage.setItem('referrerPath', ''); expect(localStorage.getItem('referrerPath')).toBe(''); @@ -425,7 +475,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); @@ -438,7 +488,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'], @@ -456,16 +506,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'); @@ -480,12 +529,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(); @@ -502,21 +551,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'); }); @@ -548,4 +596,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 c3b3b7a21..d51d53aa2 100644 --- a/packages/okta-react/README.md +++ b/packages/okta-react/README.md @@ -194,6 +194,10 @@ For PKCE flow, this should be left undefined or set to `['code']`. 1. `auth.login` is called 2. SecureRoute is accessed without 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 `login()` 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. + #### Example ```jsx @@ -377,6 +381,11 @@ Parses tokens from the url and stores them. ## 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..4131a56ab 100644 --- a/packages/okta-react/src/Auth.js +++ b/packages/okta-react/src/Auth.js @@ -46,6 +46,20 @@ export default class Auth { this.login = this.login.bind(this); this.logout = this.logout.bind(this); this.redirect = this.redirect.bind(this); + + // 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)); + } + + _onTokenError(error) { + if (error.errorCode === 'login_required') { + this.login(); + } + } + + getTokenManager() { + return this._oktaAuth.tokenManager; } async handleAuthentication() { @@ -61,8 +75,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()); } @@ -139,11 +160,7 @@ export default class Auth { || this._config.scopes || ['openid', 'email', 'profile']; - this._oktaAuth.token.getWithRedirect(params); - - // 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) => {}); + return this._oktaAuth.token.getWithRedirect(params); } + } diff --git a/packages/okta-react/test/e2e/harness/src/Home.js b/packages/okta-react/test/e2e/harness/src/Home.js index 99ab6f937..8cb54b77c 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.renew_idToken = this.renewToken.bind(this, 'idToken'); + this.renew_accessToken = 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/jest/auth.test.js b/packages/okta-react/test/jest/auth.test.js index a71f90081..9ca12d623 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'; +import tokens from '../support/tokens'; + +const { standardAccessTokenParsed, standardIdTokenParsed } = tokens; 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; + + beforeEach(() => { + mockAuthJsInstance = { + userAgent: 'okta-auth-js', + tokenManager: { + get: jest.fn().mockImplementation(tokenName => { + if (tokenName === 'idToken') { + return Promise.resolve(standardIdTokenParsed); + } else if (tokenName === 'accessToken') { + return Promise.resolve(standardAccessTokenParsed); + } 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({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + 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({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + }); + + 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({ + issuer: 'https://foo/oauth2/default', + clientId: 'foo', + redirectUri: 'https://foo/redirect', + onTokenError, + }); + }); + }); + it('should throw if no issuer is provided', () => { function createInstance () { return new Auth(); @@ -183,14 +227,7 @@ describe('Auth configuration', () => { expect(AuthJS.prototype.constructor).toHaveBeenCalledWith(options); }); -}); -describe('Auth component', () => { - beforeEach(() => { - AuthJS.mockImplementation(() => { - return mockAuthJsInstance - }); - }); test('sets the right user agent on AuthJS', () => { const auth = new Auth({ @@ -208,7 +245,7 @@ describe('Auth component', () => { redirect_uri: 'foo' }); const accessToken = await auth.getAccessToken(); - expect(accessToken).toBe(mockAccessToken); + expect(accessToken).toBe(standardAccessTokenParsed.accessToken); done(); }); test('builds the authorize request with correct params', () => { @@ -316,7 +353,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'], @@ -345,4 +382,160 @@ describe('Auth component', () => { const authenticated = await auth.isAuthenticated(); expect(authenticated).toBeFalsy(); }); + + 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, + }); + jest.spyOn(auth, 'redirect'); + + auth.login('/'); + expect(onAuthRequired).toHaveBeenCalled(); + 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(standardAccessTokenParsed)); + jest.spyOn(auth, 'getIdToken').mockReturnValue(Promise.resolve(standardIdTokenParsed)); + + 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(standardAccessTokenParsed)); + 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(standardIdTokenParsed)); + + 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/support/tokens.js b/packages/okta-react/test/support/tokens.js new file mode 100644 index 000000000..36f3de260 --- /dev/null +++ b/packages/okta-react/test/support/tokens.js @@ -0,0 +1,289 @@ +/* eslint max-statements:[2,24] */ + +var tokens = {}; + +tokens.unicodeToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + + 'eyAibXNnX2VuIjogIkhlbGxvIiwKICAibXNnX2pwIjogIuOBk-OCk-OBq' + + '-OBoeOBryIsCiAgIm1zZ19jbiI6ICLkvaDlpb0iLAogICJtc2dfa3IiOi' + + 'Ai7JWI64WV7ZWY7IS47JqUIiwKICAibXNnX3J1IjogItCX0LTRgNCw0LL' + + 'RgdGC0LLRg9C50YLQtSEiLAogICJtc2dfZGUiOiAiR3LDvMOfIEdvdHQi' + + 'IH0.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ'; + +tokens.unicodeDecoded = { + header: { + 'alg': 'HS256', + 'typ': 'JWT' + }, + payload: { + 'msg_en': 'Hello', + 'msg_jp': 'こんにちは', + 'msg_cn': '你好', + 'msg_kr': '안녕하세요', + 'msg_ru': 'Здравствуйте!', + 'msg_de': 'Grüß Gott' + }, + signature: 'TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ' +}; + +tokens.standardIdToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IlU1UjhjSGJHdzQ0NVFicTh6Vk8xUGNDcFhMOHlHNkljb3ZWYTNsYUNveE0i' + + 'fQ.eyJzdWIiOiIwMHUxcGNsYTVxWUlSRURMV0NRViIsIm5hbWUiOiJTYW1sIEphY2tzb24iLCJnaXZlbl9uYW1lIjoiU2FtbCIsImZhbWlseV9u' + + 'YW1lIjoiSmFja3NvbiIsInVwZGF0ZWRfYXQiOjE0NDYxNTM0MDEsImVtYWlsIjoic2FtbGphY2tzb25Ab2t0YS5jb20iLCJlbWFpbF92ZXJpZml' + + 'lZCI6dHJ1ZSwidmVyIjoxLCJpc3MiOiJodHRwczovL2F1dGgtanMtdGVzdC5va3RhLmNvbSIsImxvZ2luIjoiYWRtaW5Ab2t0YS5jb20iLCJub2' + + '5jZSI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJhdWQiOiJOUFNmT' + + '2tINWVaclR5OFBNRGx2eCIsImlhdCI6MTQ0OTY5NjMzMCwiZXhwIjoxNDQ5Njk5OTMwLCJhbXIiOlsia2JhIiwibWZhIiwicHdkIl0sImp0aSI6' + + 'IlRSWlQ3UkNpU3ltVHM1VzdSeWgzIiwiYXV0aF90aW1lIjoxNDQ5Njk2MzMwfQ.tdspicRE-0IrFKwjCT2Uo2gExQyTAftcp4cuA3iIF6_uYiqQ' + + '9Q4SZHCjMbuWdXrUSM-_UkDpD6sbG_ZRcdZQJ7geeIEjKpV4x792iiP_f1H-HLbAMIDWynp5FR4QQO1Q4ndNOwIsrUqf06vYazz9ildQde2uOTw' + + 'caUCsz2M0lSU'; + +tokens.standardIdTokenClaims = { + 'sub': '00u1pcla5qYIREDLWCQV', + 'name': 'Saml Jackson', + 'given_name': 'Saml', + 'family_name': 'Jackson', + 'updated_at': 1446153401, + 'email': 'samljackson@okta.com', + 'email_verified': true, + 'ver': 1, + 'iss': 'https://auth-js-test.okta.com', + 'login': 'admin@okta.com', + 'nonce': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'aud': 'NPSfOkH5eZrTy8PMDlvx', + 'iat': 1449696330, + 'exp': 1449699930, + 'amr': [ + 'kba', + 'mfa', + 'pwd' + ], + 'jti': 'TRZT7RCiSymTs5W7Ryh3', + 'auth_time': 1449696330 +}; + +tokens.standardIdTokenParsed = { + idToken: tokens.standardIdToken, + claims: tokens.standardIdTokenClaims, + expiresAt: 1449699930, + scopes: ['openid', 'email'], + authorizeUrl: 'https://auth-js-test.okta.com/oauth2/v1/authorize', + issuer: 'https://auth-js-test.okta.com', + clientId: 'NPSfOkH5eZrTy8PMDlvx' +}; + +// Uses modified nonce for testing simultaneous iframes +tokens.standardIdToken2 = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IlU1UjhjSGJHdzQ0NVFicTh6Vk8xUGNDcFhMOHlHNkljb3ZWYTNsYUNveE0' + + 'ifQ.eyJzdWIiOiIwMHUxcGNsYTVxWUlSRURMV0NRViIsIm5hbWUiOiJTYW1sIEphY2tzb24iLCJnaXZlbl9uYW1lIjoiU2FtbCIsImZhbWlseV9' + + 'uYW1lIjoiSmFja3NvbiIsInVwZGF0ZWRfYXQiOjE0NDYxNTM0MDEsImVtYWlsIjoic2FtbGphY2tzb25Ab2t0YS5jb20iLCJlbWFpbF92ZXJpZm' + + 'llZCI6dHJ1ZSwidmVyIjoxLCJpc3MiOiJodHRwczovL2F1dGgtanMtdGVzdC5va3RhLmNvbSIsImxvZ2luIjoiYWRtaW5Ab2t0YS5jb20iLCJub' + + '25jZSI6ImJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmIiLCJhdWQiOiJOUFNm' + + 'T2tINWVaclR5OFBNRGx2eCIsImlhdCI6MTQ0OTY5NjMzMCwiZXhwIjoxNDQ5Njk5OTMwLCJhbXIiOlsia2JhIiwibWZhIiwicHdkIl0sImp0aSI' + + '6IlRSWlQ3UkNpU3ltVHM1VzdSeWgzIiwiYXV0aF90aW1lIjoxNDQ5Njk2MzMwfQ.XABmqTp0TiXKu-BuvZ6XgJj11LQxXQGcludepzm71zSB38E' + + '6Td69ztugF-SVrGk_iD_k4n-lpnyfnbQt_rGFuUmAn_PsXC8DogAziSVxE96AF6G7X9rpvhnFkdc4wmt8X71oHhDuwiuAh7BrXYdvkCLDEh4Hgw' + + 'Iu4M_1dJg2308'; + +tokens.standardIdToken2Claims = { + 'sub': '00u1pcla5qYIREDLWCQV', + 'name': 'Saml Jackson', + 'given_name': 'Saml', + 'family_name': 'Jackson', + 'updated_at': 1446153401, + 'email': 'samljackson@okta.com', + 'email_verified': true, + 'ver': 1, + 'iss': 'https://auth-js-test.okta.com', + 'login': 'admin@okta.com', + 'nonce': 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'aud': 'NPSfOkH5eZrTy8PMDlvx', + 'iat': 1449696330, + 'exp': 1449699930, + 'amr': [ + 'kba', + 'mfa', + 'pwd' + ], + 'jti': 'TRZT7RCiSymTs5W7Ryh3', + 'auth_time': 1449696330 +}; + +tokens.standardIdToken2Parsed = { + idToken: tokens.standardIdToken2, + claims: tokens.standardIdToken2Claims, + expiresAt: 1449699930, + scopes: ['openid', 'email'], + authorizeUrl: 'https://auth-js-test.okta.com/oauth2/v1/authorize', + issuer: 'https://auth-js-test.okta.com', + clientId: 'NPSfOkH5eZrTy8PMDlvx' +}; + +tokens.expiredBeforeIssuedIdToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IlU1UjhjSGJHdzQ0NVFicTh6Vk8xUGNDcFhMOHlHNkljb3ZWY' + + 'TNsYUNveE0ifQ.eyJzdWIiOiIwMHUxcGNsYTVxWUlSRURMV0NRViIsIm5hbWUiOiJTYW1sIEphY2tzb24iLCJnaXZlbl9uYW1lIjoiU2FtbCIsI' + + 'mZhbWlseV9uYW1lIjoiSmFja3NvbiIsInVwZGF0ZWRfYXQiOjE0NDYxNTM0MDEsImVtYWlsIjoic2FtbGphY2tzb25Ab2t0YS5jb20iLCJlbWFp' + + 'bF92ZXJpZmllZCI6dHJ1ZSwidmVyIjoxLCJpc3MiOiJodHRwczovL2F1dGgtanMtdGVzdC5va3RhLmNvbSIsImxvZ2luIjoiYWRtaW5Ab2t0YS5' + + 'jb20iLCJub25jZSI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJhdW' + + 'QiOiJOUFNmT2tINWVaclR5OFBNRGx2eCIsImlhdCI6MTQ0OTY5NjMzMCwiZXhwIjoxNDQ5NjkwMDAwLCJhbXIiOlsia2JhIiwibWZhIiwicHdkI' + + 'l0sImp0aSI6IlRSWlQ3UkNpU3ltVHM1VzdSeWgzIiwiYXV0aF90aW1lIjoxNDQ5Njk2MzMwfQ.K6jaWgn2pX5bZx0MZBax6Y0JetCDIlJp2iUEY' + + 'PO1teGQGTGIC6qjcKlSyWVlWKTNYGJSHk24NmKa78Idxa4CaWQCaIxP_wvMJv0dQjb5nwVtyPz5X8ez46MYhkwArC2hEl9JVb2jE7ElOW2XvU5x' + + 'TaMRlXLsimDp3XNlnQ8aTiI'; + +tokens.expiredBeforeIssuedIdTokenClaims = { + 'sub': '00u1pcla5qYIREDLWCQV', + 'name': 'Saml Jackson', + 'given_name': 'Saml', + 'family_name': 'Jackson', + 'updated_at': 1446153401, + 'email': 'samljackson@okta.com', + 'email_verified': true, + 'ver': 1, + 'iss': 'https://auth-js-test.okta.com', + 'login': 'admin@okta.com', + 'nonce': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'aud': 'NPSfOkH5eZrTy8PMDlvx', + 'iat': 1449696330, + 'exp': 1449690000, + 'amr': [ + 'kba', + 'mfa', + 'pwd' + ], + 'jti': 'TRZT7RCiSymTs5W7Ryh3', + 'auth_time': 1449696330 +}; + +tokens.expiredBeforeIssuedIdTokenParsed = { + idToken: tokens.expiredBeforeIssuedIdToken, + claims: tokens.expiredBeforeIssuedIdTokenClaims, + expiresAt: 1449690000, + scopes: ['openid', 'email'], + authorizeUrl: 'https://auth-js-test.okta.com/oauth2/v1/authorize', + issuer: 'https://auth-js-test.okta.com', + clientId: 'NPSfOkH5eZrTy8PMDlvx' +}; + +tokens.authServerIdToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IlU1UjhjSGJHdzQ0NVFicTh6Vk8xUGNDcFhMOHlHNkljb3ZWYTNsYUNveE' + + '0ifQ.eyJzdWIiOiIwMHVrb2VFcUlvZ2lGSHBEZTBnMyIsImVtYWlsIjoic2FtbGphY2tzb25Ab2t0YS5jb20iLCJ2ZXIiOjEsImlzcyI6Imh0dH' + + 'BzOi8vYXV0aC1qcy10ZXN0Lm9rdGEuY29tL29hdXRoMi9hdXM4YXVzNzZxOGlwaHVwRDBoNyIsImF1ZCI6Ik5QU2ZPa0g1ZVpyVHk4UE1EbHZ4I' + + 'iwiaWF0IjoxNDQ5Njk2MzMwLCJleHAiOjE0NDk2OTk5MzAsImp0aSI6IklELlNpOUtxR3RTV2hLQnJzRGh2bEV0QVItR3lkc2V1Y1VHOXhXdVdL' + + 'MUpoNTgiLCJhbXIiOlsicHdkIl0sImlkcCI6IjAwb2tucjFDSGxXYUF3d2dvMGczIiwibm9uY2UiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWF' + + 'hYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImF1dGhfdGltZSI6MTQ0OTY5Nj' + + 'MzMH0.jy6U2EFPXrwEG7902H2vbcgkHdj7gazYo5TTS1L8jFK6pVSAfw24N1l99oxCJowRn6YnTkV8HIeR2xuBOH6rGGntSFiDl8_GoyX1xM42i' + + 'BH6R1lF9iPWhYBQg0EGKYndCXv215SaHNcxP9D3PEKq78EdUIy9EG9X37lbvVRcbBc'; + +tokens.authServerIdTokenClaims = { + 'sub': '00ukoeEqIogiFHpDe0g3', + 'email': 'samljackson@okta.com', + 'ver': 1, + 'iss': 'https://auth-js-test.okta.com/oauth2/aus8aus76q8iphupD0h7', + 'aud': 'NPSfOkH5eZrTy8PMDlvx', + 'iat': 1449696330, + 'exp': 1449699930, + 'jti': 'ID.Si9KqGtSWhKBrsDhvlEtAR-GydseucUG9xWuWK1Jh58', + 'amr': [ + 'pwd' + ], + 'idp': '00oknr1CHlWaAwwgo0g3', + 'nonce': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'email_verified': true, + 'auth_time': 1449696330 +}; + +tokens.authServerIdTokenParsed = { + idToken: tokens.authServerIdToken, + claims: tokens.authServerIdTokenClaims, + expiresAt: 1449699930, + scopes: ['openid', 'email'], + authorizeUrl: 'https://auth-js-test.okta.com/oauth2/aus8aus76q8iphupD0h7/v1/authorize', + issuer: 'https://auth-js-test.okta.com/oauth2/aus8aus76q8iphupD0h7', + clientId: 'NPSfOkH5eZrTy8PMDlvx' +}; + +tokens.modifiedIdToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IlU1UjhjSGJHdzQ0NVFicTh6Vk8xUGNDcFhMOHlHNkljb3ZWYTNsYUNveE0i' + + 'fQ.eyJzdWIiOiIwMHUxcGNsYTVxWUlSRURMV0NRViIsIm5hbWUiOiJTYW1sIEphY2tzb24iLCJnaXZlbl9uYW1lIjoiU2FtbCIsImZhbWlseV9u' + + 'YW1lIjoiSmFja3NvbiIsInVwZGF0ZWRfYXQiOjE0NDYxNTM0MDEsImVtYWlsIjoic2FtbGphY2tzb25Ab2t0YS5jb20iLCJlbWFpbF92ZXJpZml' + + 'lZCI6dHJ1ZSwidmVyIjoxLCJpc3MiOiJodHRwczovL2F1dGgtanMtdGVzdC5va3RhLmNvbSIsImxvZ2luIjoiYWRtaW5Ab2t0YS5jb20iLCJub2' + + '5jZSI6ImNjY2NjYyIsImF1ZCI6InNvbWVJZCIsImlhdCI6MTQ0OTY5NjMzMCwiZXhwIjoxNDQ5Njk5OTMwLCJhbXIiOlsia2JhIiwibWZhIiwic' + + 'HdkIl0sImp0aSI6IlRSWlQ3UkNpU3ltVHM1VzdSeWgzIiwiYXV0aF90aW1lIjoxNDQ5Njk2MzMwfQ.lVt8eAGGGUBpyrkTb2aq21wC-d-GEV-SZ' + + 'b8fCupQheQ4GOUEh4Gu2VzRuqFwORYHp177H6b91r7Z9E4L0RbkCLe_F7BmM3JD-BxziFVzIPzKBDZdkg5M12EWomxTd9n-lyYQuE4yA2lOG_W6' + + '6ldl_qLOvGlLTv52mJhOBQxW8ic'; + +tokens.modifiedIdTokenClaims = { + 'sub': '00u1pcla5qYIREDLWCQV', + 'name': 'Saml Jackson', + 'given_name': 'Saml', + 'family_name': 'Jackson', + 'updated_at': 1446153401, + 'email': 'samljackson@okta.com', + 'email_verified': true, + 'ver': 1, + 'iss': 'https://auth-js-test.okta.com', + 'login': 'admin@okta.com', + 'nonce': 'cccccc', + 'aud': 'someId', + 'iat': 1449696330, + 'exp': 1449699930, + 'amr': [ + 'kba', + 'mfa', + 'pwd' + ], + 'jti': 'TRZT7RCiSymTs5W7Ryh3', + 'auth_time': 1449696330 +}; + +tokens.standardAccessToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2ZXIiOj' + + 'EsImp0aSI6IkFULnJ2Ym5TNGlXdTJhRE5jYTNid1RmMEg5Z' + + 'VdjV2xsS1FlaU5ZX1ZlSW1NWkEiLCJpc3MiOiJodHRwczov' + + 'L2xib3lldHRlLnRyZXhjbG91ZC5jb20vYXMvb3JzMXJnM3p' + + '5YzhtdlZUSk8wZzciLCJhdWQiOiJodHRwczovL2xib3lldH' + + 'RlLnRyZXhjbG91ZC5jb20vYXMvb3JzMXJnM3p5YzhtdlZUS' + + 'k8wZzciLCJzdWIiOiIwMHUxcGNsYTVxWUlSRURMV0NRViIs' + + 'ImlhdCI6MTQ2ODQ2NzY0NywiZXhwIjoxNDY4NDcxMjQ3LCJ' + + 'jaWQiOiJQZjBhaWZyaFladTF2MFAxYkZGeiIsInVpZCI6Ij' + + 'AwdTFwY2xhNXFZSVJFRExXQ1FWIiwic2NwIjpbIm9wZW5pZ' + + 'CIsImVtYWlsIl19.ziKfS8IjSdOdTHCZllTDnLFdE96U9bS' + + 'IsJzI0MQ0zlnM2QiiA7nvS54k6Xy78ebnkJvmeMCctjXVKk' + + 'JOEhR6vs11qVmIgbwZ4--MqUIRU3WoFEsr0muLl039QrUa1' + + 'EQ9-Ua9rPOMaO0pFC6h2lfB_HfzGifXATKsN-wLdxk6cgA'; + +tokens.standardAccessTokenParsed = { + accessToken: tokens.standardAccessToken, + expiresAt: 1449703529, // assuming time = 1449699929 + scopes: ['openid', 'email'], + tokenType: 'Bearer', + authorizeUrl: 'https://auth-js-test.okta.com/oauth2/v1/authorize', + userinfoUrl: 'https://auth-js-test.okta.com/oauth2/v1/userinfo' +}; + +tokens.authServerAccessToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2ZXIiOjEsImp' + + '0aSI6IkFULl8wWTNCYkV5X2Y0MlNjUzJWT3drc1RwOEI4UW9qWVM' + + 'zYk10WENERnJ4aDgiLCJpc3MiOiJodHRwczovL2F1dGgtanMtdGV' + + 'zdC5va3RhLmNvbS9vYXV0aDIvYXVzOGF1czc2cThpcGh1cEQwaDc' + + 'iLCJhdWQiOiJodHRwOi8vZXhhbXBsZS5jb20iLCJzdWIiOiJzYW1' + + 'samFja3NvbkBva3RhLmNvbSIsImlhdCI6MTQ0OTY5OTkyOSwiZXh' + + 'wIjoxNDQ5NzAzNTI5LCJjaWQiOiJnTHpGMERoalFJR0NUNHFPMFN' + + 'NQiIsInVpZCI6IjAwdWtvZUVxSW9naUZIcERlMGczIiwic2NwIjp' + + 'bIm9wZW5pZCIsImVtYWlsIl19.sD7CmiX1JCrngJFbYid5za78-c' + + 'vOdVEFONqx7m5Ar8OK3MWPuui9wbzBvyiBR70rCuKzb0gSZb96N0' + + 'EE8wXbgYjzGH5T6dazwgGfGmVf2PTa1pKfPew7f_XKE_t1O_tJ9C' + + 'h9gY9Z3xd92ac407ZIOHkabLvZ0-45ANM3Gm0LC0c'; + +tokens.authServerAccessTokenParsed = { + accessToken: tokens.authServerAccessToken, + expiresAt: 1449703529, // assuming time = 1449699929 + scopes: ['openid', 'email'], + tokenType: 'Bearer', + authorizeUrl: 'https://auth-js-test.okta.com/oauth2/aus8aus76q8iphupD0h7/v1/authorize', + userinfoUrl: 'https://auth-js-test.okta.com/oauth2/aus8aus76q8iphupD0h7/v1/userinfo' +}; + +tokens.standardAuthorizationCode = '35cFyfgCU2u0a1EzAqbO'; + +tokens.standardKey = { + alg: 'RS256', + kty: 'RSA', + n: '3ZWrUY0Y6IKN1qI4BhxR2C7oHVFgGPYkd38uGq1jQNSqEvJFcN93CYm16_G78FAFKWqwsJb3Wx-nbxDn6LtP4AhULB1H0K0g7_jLklDAHvI8' + + 'yhOKlvoyvsUFPWtNxlJyh5JJXvkNKV_4Oo12e69f8QCuQ6NpEPl-cSvXIqUYBCs', + e: 'AQAB', + use: 'sig', + kid: 'U5R8cHbGw445Qbq8zVO1PcCpXL8yG6IcovVa3laCoxM' +}; + +export default tokens; diff --git a/packages/okta-vue/README.md b/packages/okta-vue/README.md index 09e82cd60..bebbc74ef 100644 --- a/packages/okta-vue/README.md +++ b/packages/okta-vue/README.md @@ -223,6 +223,10 @@ he most commonly used options are shown here. See [Configuration Reference](http - `autoRenew` *(optional)*: By default, the library will attempt to renew expired tokens. When an expired token is requested by the library, a renewal request is executed to update the token. If you wish to to disable auto renewal of tokens, set `autoRenew` to `false`. +-`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. @@ -259,6 +263,11 @@ Parses the tokens returned as hash fragments in the OAuth 2.0 Redirect URI. ## 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..e54294375 100644 --- a/packages/okta-vue/src/Auth.js +++ b/packages/okta-vue/src/Auth.js @@ -8,85 +8,118 @@ 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 loginRedirect (fromUri, additionalParams) { + if (fromUri) { + localStorage.setItem('referrerPath', 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) + }) + } + + 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.loginRedirect(to.path) + } else { + next() } } } + + getTokenManager () { + return this.oktaAuth.tokenManager + } + + // Handle token manager errors: Default implementation + _onTokenError (error) { + if (error.errorCode === 'login_required') { + this.loginRedirect() + } + } +} + +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 0dc75dc9d..84dd346c3 100644 --- a/packages/okta-vue/test/jest/Auth.config.spec.js +++ b/packages/okta-vue/test/jest/Auth.config.spec.js @@ -1,9 +1,20 @@ import { createLocalVue } from '@vue/test-utils' import { default as Auth } from '../../src/Auth' +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..c552ca2eb 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 }) @@ -87,11 +98,11 @@ describe('loginRedirect', () => { let mockAuthJsInstance let localVue beforeEach(() => { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ token: { getWithRedirect: jest.fn() } - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -130,12 +141,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 +168,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 +189,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 +200,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 +208,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 +227,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 }) @@ -247,11 +272,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 +297,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 +322,7 @@ describe('getUser', () => { let localVue function bootstrap (options = {}) { - mockAuthJsInstance = { + mockAuthJsInstance = extendMockAuthJS({ token: { getUserInfo: jest.fn().mockReturnValue(Promise.resolve(options.userInfo)) }, @@ -310,7 +335,7 @@ describe('getUser', () => { } }) } - } + }) AuthJS.mockImplementation(() => { return mockAuthJsInstance }) @@ -369,3 +394,51 @@ 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, 'loginRedirect').mockReturnValue(null) + localVue.prototype.$auth._onTokenError({ errorCode: 'login_required' }) + expect(localVue.prototype.$auth.loginRedirect).toHaveBeenCalled() + }) + + it('_onTokenError: ignores other errors', () => { + bootstrap() + jest.spyOn(localVue.prototype.$auth, 'loginRedirect').mockReturnValue(null) + localVue.prototype.$auth._onTokenError({ errorCode: 'something' }) + expect(localVue.prototype.$auth.loginRedirect).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) + }) +}) 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)