diff --git a/CHANGELOG.md b/CHANGELOG.md index 395521fc..12ec9997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# 3.1.0 + +### Features + +[#33](https://github.com/okta/okta-angular/pull/33) Adds option `onAuthResume` to resume authorization flow on custom login page. + +# 3.0.1 + +### Bug Fixes + +[#9](https://github.com/okta/okta-angular/pull/9) fix: handle --base-href option + # 3.0.0 [#5](https://github.com/okta/okta-angular/pull/5) Release 3.0.0 - `OktaAuthService` now inherits from an instance of `@okta/okta-auth-js` so all configuration options and public methods are available. See [MIGRATING](MIGRATING.md) for detailed information. diff --git a/README.md b/README.md index d0974cd4..3e4ab45f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [Dependency Injection]: https://angular.io/guide/dependency-injection [OktaAuthService]: #oktaauthservice [AuthState]: https://github.com/okta/okta-auth-js#authstatemanager +[external identity provider]: https://developer.okta.com/docs/concepts/identity-providers/ # Okta Angular SDK @@ -75,7 +76,7 @@ import { } from '@okta/okta-angular'; const oktaConfig = { - issuer: 'https://{yourOktaDomain}.com/oauth2/default', + issuer: 'https://{yourOktaDomain}/oauth2/default', clientId: '{clientId}', redirectUri: window.location.origin + '/login/callback' } @@ -102,7 +103,8 @@ An Angular InjectionToken used to configure the OktaAuthService. This value must This SDK accepts all configuration options defined by [@okta/okta-auth-js][] and adds some additional options: -- `onAuthRequired` *(optional)*: - callback function. Called when authentication is required. If not supplied, `okta-angular` will redirect directly to Okta for authentication. This is triggered when a route protected by `OktaAuthGuard` is accessed without authentication. +- `onAuthRequired` *(optional)*: - callback function. Triggered when a route protected by `OktaAuthGuard` is accessed without authentication. Use this to present a [custom login page](#using-a-custom-login-page). If no `onAuthRequired` callback is defined, `okta-angular` will redirect directly to Okta for authentication. +- `onAuthResume` *(optional)*: - callback function. Only relevant if using a [custom login page](#using-a-custom-login-page). Called when the [authentication flow should be resumed by the application](#resuming-the-authentication-flow), typically as a result of redirect callback from an [external identity provider][]. If not defined, `onAuthRequired` will be called. - `isAuthenticated` *(optional)* - callback function. By default, [OktaAuthService.isAuthenticated()](https://github.com/okta/okta-auth-js#isauthenticatedtimeout) will return true if **both** [getIdToken()](https://github.com/okta/okta-auth-js#getidtoken) **and** [getAccessToken()](https://github.com/okta/okta-auth-js#getaccesstoken) return a value. Setting an `isAuthenticated` function on the config allows you to customize this logic. The function receives an instance of `OktaAuthService` as a parameter and should return a Promise which resolves to either true or false. ### `OktaAuthModule` @@ -258,6 +260,34 @@ const appRoutes: Routes = [ ] ``` +##### Resuming the authentication flow + +When using a custom login page and an [external identity provider][] your app should be prepared to handle a redirect callback from Okta to resume the authentication flow. The `OktaCallbackComponent` has built-in logic for this scenario. + +The `redirectUri` of your application will be requested with a special parameter (`?error=interaction_required`) to indicate that the authentication flow should be resumed by the application. In this case, the `OktaCallbackComponent` will call the `onAuthResume` function (if set on `OktaConfig`). If `onAuthResume` is not defined, then `onAuthRequired` will be called (if defined). If neither method is set in `OktaConfig`, then the `interaction_required` error will be displayed as a string. + +If the authentication flow began on the custom login page using the [Okta SignIn Widget][], the transaction will automatically resume when the widget is rendered again on the custom login page. + +Note that `onAuthResume` has the same signature as `onAuthRequired`. If you do not need any special logic for resuming an authorization flow, you can define only an `onAuthRequired` method and it will be called both to start or resume an auth flow. + +```typescript +// myApp.module.ts + +function onAuthResume(oktaAuth, injector) { + // Use injector to access any service available within your application + const router = injector.get(Router); + + // Redirect the user to custom login page which renders the Okta SignIn Widget + router.navigate(['/custom-login']); +} + +const oktaConfig = { + issuer: environment.ISSUER, + ... + onAuthResume: onAuthResume +}; +``` + ### `OktaAuthService` In your components, your can take advantage of all of `okta-angular`'s features by importing the `OktaAuthService`. The `OktaAuthService` inherits from the `OktaAuth` service exported by [@okta/okta-auth-jks][] making the full [configuration](https://github.com/okta/okta-auth-js#configuration-reference) and [api](https://github.com/okta/okta-auth-js#api-reference) available on `OktaAuthService`. diff --git a/package.json b/package.json index 39e759ad..bb00b8a9 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ ], "license": "Apache-2.0", "dependencies": { - "@okta/okta-auth-js": "^4.1.0", + "@okta/okta-auth-js": "^4.8.0", "tslib": "^1.9.0" }, "devDependencies": { diff --git a/src/okta/components/callback.component.ts b/src/okta/components/callback.component.ts index ecf85ea3..87cd71b3 100644 --- a/src/okta/components/callback.component.ts +++ b/src/okta/components/callback.component.ts @@ -10,23 +10,31 @@ * See the License for the specific language governing permissions and limitations under the License. */ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, Optional, Injector } from '@angular/core'; import { Router } from '@angular/router'; import { OktaAuthService } from '../services/okta.service'; - @Component({ template: `
{{error}}
` }) export class OktaCallbackComponent implements OnInit { error: string; - constructor(private okta: OktaAuthService, private router: Router) {} + constructor(private okta: OktaAuthService, private router: Router, @Optional() private injector?: Injector) {} async ngOnInit(): Promise { try { // Parse code or tokens from the URL, store tokens in the TokenManager, and redirect back to the originalUri await this.okta.handleLoginRedirect(); } catch (e) { + // Callback from social IDP. Show custom login page to continue. + if (this.okta.isInteractionRequiredError(e) && this.injector) { + const { onAuthResume, onAuthRequired } = this.okta.getOktaConfig(); + const callbackFn = onAuthResume || onAuthRequired; + if (callbackFn) { + callbackFn(this.okta, this.injector); + return; + } + } this.error = e.toString(); } } diff --git a/src/okta/models/okta.config.ts b/src/okta/models/okta.config.ts index 6aad5eca..1b388f44 100644 --- a/src/okta/models/okta.config.ts +++ b/src/okta/models/okta.config.ts @@ -23,6 +23,7 @@ export interface TestingObject { export interface OktaConfig extends OktaAuthOptions { onAuthRequired?: AuthRequiredFunction; + onAuthResume?: AuthRequiredFunction; testing?: TestingObject; isAuthenticated?: IsAuthenticatedFunction; } diff --git a/test/spec/callback.component.test.ts b/test/spec/callback.component.test.ts index 9b267b62..e080670d 100644 --- a/test/spec/callback.component.test.ts +++ b/test/spec/callback.component.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { TestBed, async, ComponentFixture } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; @@ -22,11 +23,17 @@ describe('OktaCallbackComponent', () => { href: 'https://foo', search: '?code=fake' }; - const config = { + }); + afterEach(() => { + window.location = originalLocation; + }); + + function bootstrap(config = {}) { + config = Object.assign({ clientId: 'foo', issuer: 'https://foo', redirectUri: 'https://foo' - }; + }, config); TestBed.configureTestingModule({ imports: [ @@ -46,21 +53,22 @@ describe('OktaCallbackComponent', () => { service = TestBed.get(OktaAuthService); fixture = TestBed.createComponent(OktaCallbackComponent); component = fixture.componentInstance; - }); - afterEach(() => { - window.location = originalLocation; - }); + } + it('should create the component', async(() => { + bootstrap(); expect(component).toBeTruthy(); })); it('should call handleLoginRedirect', async(() => { + bootstrap(); jest.spyOn(service, 'handleLoginRedirect').mockReturnValue(Promise.resolve()); fixture.detectChanges(); expect(service.handleLoginRedirect).toHaveBeenCalled(); })); it('catches errors from handleLoginRedirect', async(() => { + bootstrap(); const error = new Error('test error'); jest.spyOn(service, 'handleLoginRedirect').mockReturnValue(Promise.reject(error)); fixture.detectChanges(); @@ -69,4 +77,46 @@ describe('OktaCallbackComponent', () => { expect(component.error).toBe('Error: test error'); }); })); + + describe('interaction code flow', () => { + it('will call `onAuthResume` function, if defined', async(() => { + const onAuthResume = jest.fn(); + bootstrap({ onAuthResume }); + const error = new Error('my fake error'); + jest.spyOn(service, 'handleLoginRedirect').mockReturnValue(Promise.reject(error)); + jest.spyOn(service, 'isInteractionRequiredError').mockReturnValue(true); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(service.isInteractionRequiredError).toHaveBeenCalledWith(error); + expect(onAuthResume).toHaveBeenCalledWith(service, (component as any).injector); + expect(component.error).toBe(undefined); + }); + })); + + it('will call `onAuthRequired` function, if `onAuthResume` is not defined', async(() => { + const onAuthRequired = jest.fn(); + bootstrap({ onAuthRequired }); + const error = new Error('my fake error'); + jest.spyOn(service, 'handleLoginRedirect').mockReturnValue(Promise.reject(error)); + jest.spyOn(service, 'isInteractionRequiredError').mockReturnValue(true); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(service.isInteractionRequiredError).toHaveBeenCalledWith(error); + expect(onAuthRequired).toHaveBeenCalledWith(service, (component as any).injector); + expect(component.error).toBe(undefined); + }); + })); + + it('if neither `onAuthRequired` or `onAuthResume` are defined, the error is displayed', async(() => { + bootstrap(); + const error = new Error('my fake error'); + jest.spyOn(service, 'handleLoginRedirect').mockReturnValue(Promise.reject(error)); + jest.spyOn(service, 'isInteractionRequiredError').mockReturnValue(true); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(service.isInteractionRequiredError).toHaveBeenCalledWith(error); + expect(component.error).toBe('Error: my fake error'); + }); + })); + }); }); diff --git a/yarn.lock b/yarn.lock index ab8beb5d..45561ae3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1405,17 +1405,18 @@ mkdirp "^1.0.4" rimraf "^3.0.2" -"@okta/okta-auth-js@^4.1.0": - version "4.7.2" - resolved "https://registry.yarnpkg.com/@okta/okta-auth-js/-/okta-auth-js-4.7.2.tgz#528a0dce0dcc82d31af71d31e2a77215d3f43477" - integrity sha512-eYoE7kPKMZ5JkYAak5+AlOpX2ZVn1dGIjqVrBLdgIUxO0FX7F3ys1rqPmC+eZMfgHGiLvA9/xHMD8BdX0sck2g== +"@okta/okta-auth-js@^4.8.0": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@okta/okta-auth-js/-/okta-auth-js-4.8.0.tgz#b745bc826b0df16f51f481a07a89f3387d48ab45" + integrity sha512-uaF2jS6avN0BNJ8EuJYM+KIwm3KZRo4QNAnEUDppysnxRtPlgExU2ndMEPZoSyYbCRdEd1x8UuSWTxrXhXThKQ== dependencies: "@babel/runtime" "^7.12.5" - Base64 "0.3.0" + Base64 "1.1.0" core-js "^3.6.5" cross-fetch "^3.0.6" - js-cookie "2.2.0" - node-cache "^4.2.0" + js-cookie "2.2.1" + karma-coverage "^2.0.3" + node-cache "^5.1.2" p-cancelable "^2.0.0" text-encoding "^0.7.0" tiny-emitter "1.1.0" @@ -2045,10 +2046,10 @@ resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== -Base64@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/Base64/-/Base64-0.3.0.tgz#6da261a4e80d4fa0f5c684254e5bccd16bbdce9f" - integrity sha1-baJhpOgNT6D1xoQlTlvM0Wu9zp8= +Base64@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/Base64/-/Base64-1.1.0.tgz#810ef21afa8357df92ad7b5389188c446b9cb956" + integrity sha512-qeacf8dvGpf+XAT27ESHMh7z84uRzj/ua2pQdJg483m3bEXv/kVFtDnMgvf70BQGqzbZhR9t6BmASzKvqfJf3Q== JSONStream@^1.3.4: version "1.3.5" @@ -6907,7 +6908,7 @@ istanbul-lib-instrument@^1.10.2: istanbul-lib-coverage "^1.2.1" semver "^5.3.0" -istanbul-lib-instrument@^4.0.0, istanbul-lib-instrument@^4.0.3: +istanbul-lib-instrument@^4.0.0, istanbul-lib-instrument@^4.0.1, istanbul-lib-instrument@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== @@ -6963,7 +6964,7 @@ istanbul-reports@^1.5.1: dependencies: handlebars "^4.0.3" -istanbul-reports@^3.0.2: +istanbul-reports@^3.0.0, istanbul-reports@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.0.2.tgz#d593210e5000683750cb09fc0644e4b6e27fd53b" integrity sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw== @@ -7416,10 +7417,10 @@ jest@^26.4.2: import-local "^3.0.2" jest-cli "^26.6.3" -js-cookie@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.0.tgz#1b2c279a6eece380a12168b92485265b35b1effb" - integrity sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s= +js-cookie@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" + integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" @@ -7619,6 +7620,18 @@ karma-coverage-istanbul-reporter@^1.2.1: istanbul-api "^1.3.1" minimatch "^3.0.4" +karma-coverage@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/karma-coverage/-/karma-coverage-2.0.3.tgz#c10f4711f4cf5caaaa668b1d6f642e7da122d973" + integrity sha512-atDvLQqvPcLxhED0cmXYdsPMCQuh6Asa9FMZW1bhNqlVEhJoB9qyZ2BY1gu7D/rr5GLGb5QzYO4siQskxaWP/g== + dependencies: + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^4.0.1" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.0.0" + minimatch "^3.0.4" + karma-jasmine-html-reporter@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-0.2.2.tgz#48a8e5ef18807617ee2b5e33c1194c35b439524c" @@ -8499,13 +8512,12 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-cache@^4.2.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-4.2.1.tgz#efd8474dee4edec4138cdded580f5516500f7334" - integrity sha512-BOb67bWg2dTyax5kdef5WfU3X8xu4wPg+zHzkvls0Q/QpYycIFRLEEIdAx9Wma43DxG6Qzn4illdZoYseKWa4A== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== dependencies: clone "2.x" - lodash "^4.17.15" node-fetch-npm@^2.0.2: version "2.0.4"