diff --git a/change/@azure-msal-angular-4617b0d3-97c7-4caf-8514-c3c2122af8cc.json b/change/@azure-msal-angular-4617b0d3-97c7-4caf-8514-c3c2122af8cc.json new file mode 100644 index 0000000000..f7c3ab28d5 --- /dev/null +++ b/change/@azure-msal-angular-4617b0d3-97c7-4caf-8514-c3c2122af8cc.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Msal guard supports angular routes for login failure (#2803)", + "packageName": "@azure/msal-angular", + "email": "joarroyo@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/lib/msal-angular/docs/v2-docs/1.x-2.x-upgrade-guide.md b/lib/msal-angular/docs/v2-docs/1.x-2.x-upgrade-guide.md index d5b1f8cb69..7a1e3746f9 100644 --- a/lib/msal-angular/docs/v2-docs/1.x-2.x-upgrade-guide.md +++ b/lib/msal-angular/docs/v2-docs/1.x-2.x-upgrade-guide.md @@ -42,18 +42,22 @@ See the [updated sample](https://github.com/AzureAD/microsoft-authentication-lib * The `logger` is now set through configurations for the MSAL instance, under `system.loggerOptions`, which include a `loggerCallback`, `piiLoggingEnabled` and `logLevel`, instead of an instance of a `logger`. The `logger` can also be set dynamically by using `MsalService.setLogger()`. See the [`logger documentation`](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/docs/v2-docs/logging.md) for more information and [sample](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/samples/msal-angular-v2-samples/angular10-sample-app/src/app/app.module.ts) for usage. ### API changes + * The `acquireToken` and `login` methods now take different request objects as parameters. See the [msal.service.ts](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/src/msal.service.ts) for details. * Broadcast events now emit an `EventMessage` object, instead of just strings. See the [Angular sample](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/samples/msal-angular-v2-samples/angular10-sample-app/src/app/app.component.ts) for an example of how to implement. * Applications using `Redirect` methods must implement the `handleRedirectObservable` method (and have it run on every page load), which will capture the result of redirect operations. See the [Angular sample](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/samples/msal-angular-v2-samples/angular10-sample-app/src/app/home/home.component.ts) for an example of how to implement. +### MSAL Guard + +* **Interfaces**: `MsalGuard` now implements `CanActivateChild` and `CanLoad` in addition to `CanActivate`. Example code snippets are provided in our [initialization doc](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/docs/v2-docs/initialization.md#secure-the-routes-in-your-application) and examples of usage can be found in our sample application [here](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app-routing.module.ts). +* **Redirect on failure**: `MsalGuard` configuration now has a `loginFailedRoute` that can be configured. The Guard will redirect to this page if login is required and fails. See the Angular sample for examples of implementing it in the [configuration](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/c0609f899704215515eeeac77e9885228d6d5dbb/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app.module.ts#L48) and [app routing module](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/c0609f899704215515eeeac77e9885228d6d5dbb/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app-routing.module.ts#L40). + ### Accounts + * When getting accounts, we recommend using `getAccountByHomeId()` and `getAccountByLocalId()`, available on the MSAL instance. `getAccount()` is now `getAccountByUsername()`, but should be a secondary choice, as it may be less reliable and is for convenience only. * `getAllAccounts()` is also available on the MSAL instance. Please see [docs](https://azuread.github.io/microsoft-authentication-library-for-js/ref/msal-browser/classes/_src_app_clientapplication_.clientapplication.html) for `@azure/msal-browser` for more details on account methods. * Additionally, you can now get and set active acccounts using `getActiveAccount()` and `setActiveAccount()`. We recommend setting the active account after logging in with popups or calling `handleRedirectObservable()`. See [our sample](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/513855f780aef1cb1c905944ec3ba139623addf3/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app.component.ts#L48) for examples of its use. -### MSAL Guard Interfaces -* `MsalGuard` now implements `CanActivateChild` and `CanLoad` in addition to `CanActivate`. Example code snippets are provided in our [initialization doc](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/docs/v2-docs/initialization.md#secure-the-routes-in-your-application) and examples of usage can be found in our sample application [here](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app-routing.module.ts). - ## Angular 9+ and rxjs@6 MSAL Angular now expects that your application is built with `@angular/core@>=9`, `@angular/common@>=9`, `rxjs@6`. As with MSAL Angular 1.x, `rxjs-compat` is not required. diff --git a/lib/msal-angular/docs/v2-docs/configuration.md b/lib/msal-angular/docs/v2-docs/configuration.md index c90ca600e4..ea9c2864bc 100644 --- a/lib/msal-angular/docs/v2-docs/configuration.md +++ b/lib/msal-angular/docs/v2-docs/configuration.md @@ -14,7 +14,12 @@ MSAL for Angular accepts three configuration objects: 2. [`MsalGuardConfiguration`](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/src/msal.guard.config.ts): A set of options specifically for the Angular guard. 3. [`MsalInterceptorConfiguration`](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/src/msal.interceptor.config.ts): A set of options specifically for the Angular interceptor. -An `authRequest` object can be specified on `MsalGuardConfiguration` and `MsalInterceptorConfiguration` to set additional options. This is an advanced featured that is not required. All possible parameters for the request object can be found here: [`PopupRequest`](https://azuread.github.io/microsoft-authentication-library-for-js/ref/msal-browser/modules/_src_request_popuprequest_.html) and [`RedirectRequest`](https://azuread.github.io/microsoft-authentication-library-for-js/ref/msal-browser/modules/_src_request_redirectrequest_.html). +### Angular-specific configurations + +* An `interactionType` must be specified on `MsalGuardConfiguration` and `MsalInterceptorConfiguration`, and can be set to `Popup` or `Redirect`. +* An `authRequest` object can be specified on `MsalGuardConfiguration` and `MsalInterceptorConfiguration` to set additional options. This is an advanced featured that is not required. All possible parameters for the request object can be found here: [`PopupRequest`](https://azuread.github.io/microsoft-authentication-library-for-js/ref/msal-browser/modules/_src_request_popuprequest_.html) and [`RedirectRequest`](https://azuread.github.io/microsoft-authentication-library-for-js/ref/msal-browser/modules/_src_request_redirectrequest_.html). +* The `protectedResourceMap` object on `MsalInterceptorConfiguration` is used to protect routes. See the [upgrade guide](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/docs/v2-docs/1.x-2.x-upgrade-guide.md) to see how to use wildcards, and how this differs from MSAL 1.x. +* The `loginFailedRoute` string can be set on `MsalGuardConfiguration`. Msal Guard will redirect to this route if login is required and fails. ## MsalModule.forRoot @@ -46,6 +51,7 @@ import { IPublicClientApplication, PublicClientApplication, InteractionType, Bro } }, { interactionType: InteractionType.Popup // MSAL Guard Configuration + loginFailedRoute: "/login-failed" }, { interactionType: InteractionType.Redirect, // MSAL Interceptor Configuration protectedResourceMap @@ -103,7 +109,10 @@ export function MSALInterceptorConfigFactory(): MsalInterceptorConfig { } export function MSALGuardConfigFactory(): MsalGuardConfiguration { - return { interactionType: InteractionType.Redirect }; + return { + interactionType: InteractionType.Redirect, + loginFailedRoute: "./login-failed" + }; } @NgModule({ diff --git a/lib/msal-angular/src/constants.ts b/lib/msal-angular/src/constants.ts index 868df08049..c6c32a86a1 100644 --- a/lib/msal-angular/src/constants.ts +++ b/lib/msal-angular/src/constants.ts @@ -13,4 +13,4 @@ export const MSAL_INTERCEPTOR_CONFIG = new InjectionToken("MSAL_INTERCEP export const name = "@azure/msal-angular"; -export const version = "2.0.0-alpha.0"; +export const version = "2.0.0-alpha.1"; diff --git a/lib/msal-angular/src/msal.guard.config.ts b/lib/msal-angular/src/msal.guard.config.ts index ec288f37ad..1bc29dc41a 100644 --- a/lib/msal-angular/src/msal.guard.config.ts +++ b/lib/msal-angular/src/msal.guard.config.ts @@ -8,4 +8,5 @@ import { PopupRequest, RedirectRequest,InteractionType } from "@azure/msal-brows export type MsalGuardConfiguration = { interactionType: InteractionType.Popup | InteractionType.Redirect; authRequest?: Partial | Partial>; + loginFailedRoute?: string; }; diff --git a/lib/msal-angular/src/msal.guard.spec.ts b/lib/msal-angular/src/msal.guard.spec.ts index 6777bf095d..527053d5ff 100644 --- a/lib/msal-angular/src/msal.guard.spec.ts +++ b/lib/msal-angular/src/msal.guard.spec.ts @@ -1,6 +1,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; +import { Router, UrlTree } from '@angular/router'; import { BrowserUtils, InteractionType, IPublicClientApplication, PublicClientApplication, UrlString } from '@azure/msal-browser'; import { of } from 'rxjs'; import { MsalGuardConfiguration } from './msal.guard.config'; @@ -12,6 +12,7 @@ let routeMock: any = { snapshot: {} }; let routeStateMock: any = { snapshot: {}, url: '/' }; let routerMock = { navigate: jasmine.createSpy('navigate') }; let testInteractionType: InteractionType; +let testLoginFailedRoute: string; function MSALInstanceFactory(): IPublicClientApplication { return new PublicClientApplication({ @@ -25,7 +26,8 @@ function MSALInstanceFactory(): IPublicClientApplication { function MSALGuardConfigFactory(): MsalGuardConfiguration { return { //@ts-ignore - interactionType: testInteractionType + interactionType: testInteractionType, + loginFailedRoute: testLoginFailedRoute } } @@ -54,6 +56,7 @@ function initializeMsal() { describe('MsalGuard', () => { beforeEach(() => { testInteractionType = InteractionType.Popup; + testLoginFailedRoute = undefined; initializeMsal(); }); @@ -110,7 +113,7 @@ describe('MsalGuard', () => { done(); }); - it("should return false after login with popup fails", (done) => { + it("should return false after login with popup fails and no loginFailedRoute set", (done) => { spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( //@ts-ignore of("test") @@ -126,6 +129,29 @@ describe('MsalGuard', () => { done(); }); + it("should return loginFailedRoute after login with popup fails and loginFailedRoute set", (done) => { + testLoginFailedRoute = "failed"; + initializeMsal(); + + spyOn(guard, "parseUrl").and.returnValue( + testLoginFailedRoute as unknown as UrlTree + ) + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([]); + + spyOn(MsalService.prototype, "loginPopup").and.throwError("login error"); + + const listener = jasmine.createSpy(); + guard.canActivate(routeMock, routeStateMock).subscribe(listener); + expect(listener).toHaveBeenCalledWith("failed"); + done(); + }); + it("should return false after logging in with redirect", (done) => { testInteractionType = InteractionType.Redirect; initializeMsal(); diff --git a/lib/msal-angular/src/msal.guard.ts b/lib/msal-angular/src/msal.guard.ts index d60f6f773b..3a18ea0421 100644 --- a/lib/msal-angular/src/msal.guard.ts +++ b/lib/msal-angular/src/msal.guard.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, CanLoad } from "@angular/router"; +import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, CanActivateChild, CanLoad, UrlTree, Router } from "@angular/router"; import { MsalService } from "./msal.service"; import { Injectable, Inject } from "@angular/core"; import { Location } from "@angular/common"; @@ -15,12 +15,23 @@ import { Observable, of } from "rxjs"; @Injectable() export class MsalGuard implements CanActivate, CanActivateChild, CanLoad { + private loginFailedRoute?: UrlTree; + constructor( @Inject(MSAL_GUARD_CONFIG) private msalGuardConfig: MsalGuardConfiguration, private authService: MsalService, private location: Location, + private router: Router ) { } + /** + * Parses url string to UrlTree + * @param url + */ + parseUrl(url: string): UrlTree { + return this.router.parseUrl(url); + } + /** * Builds the absolute url for the destination page * @param path Relative path of requested page @@ -57,8 +68,7 @@ export class MsalGuard implements CanActivate, CanActivateChild, CanLoad { this.authService.getLogger().verbose("Guard - login by popup successful, can activate, setting active account"); this.authService.instance.setActiveAccount(response.account); return true; - }), - catchError(() => of(false)) + }) ); } @@ -71,7 +81,7 @@ export class MsalGuard implements CanActivate, CanActivateChild, CanLoad { return of(false); } - private activateHelper(state?: RouterStateSnapshot): Observable { + private activateHelper(state?: RouterStateSnapshot): Observable { if (this.msalGuardConfig.interactionType !== InteractionType.Popup && this.msalGuardConfig.interactionType !== InteractionType.Redirect) { throw new BrowserConfigurationAuthError("invalid_interaction_type", "Invalid interaction type provided to MSAL Guard. InteractionType.Popup or InteractionType.Redirect must be provided in the MsalGuardConfiguration"); } @@ -87,6 +97,13 @@ export class MsalGuard implements CanActivate, CanActivateChild, CanLoad { return of(false); } + /** + * If a loginFailedRoute is set in the config, set this as the loginFailedRoute + */ + if (this.msalGuardConfig.loginFailedRoute) { + this.loginFailedRoute = this.parseUrl(this.msalGuardConfig.loginFailedRoute); + } + return this.authService.handleRedirectObservable() .pipe( concatMap(() => { @@ -103,22 +120,26 @@ export class MsalGuard implements CanActivate, CanActivateChild, CanLoad { }), catchError(() => { this.authService.getLogger().verbose("Guard - error while logging in, unable to activate"); + if (this.loginFailedRoute) { + this.authService.getLogger().verbose("Guard - loginFailedRoute set, redirecting"); + return of(this.loginFailedRoute); + } return of(false); }) ); } - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { this.authService.getLogger().verbose("Guard - canActivate"); return this.activateHelper(state); } - canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { this.authService.getLogger().verbose("Guard - canActivateChild"); return this.activateHelper(state); } - canLoad(): Observable { + canLoad(): Observable { this.authService.getLogger().verbose("Guard - canLoad"); return this.activateHelper(); } diff --git a/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app-routing.module.ts b/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app-routing.module.ts index f84fe14709..0e3ce88ce9 100644 --- a/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app-routing.module.ts +++ b/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app-routing.module.ts @@ -4,6 +4,7 @@ import { ProfileComponent } from './profile/profile.component'; import { HomeComponent } from './home/home.component'; import { MsalGuard } from '@azure/msal-angular'; import { DetailComponent } from './detail/detail.component'; +import { FailedComponent } from './failed/failed.component'; const routes: Routes = [ { @@ -34,6 +35,10 @@ const routes: Routes = [ { path: '', component: HomeComponent + }, + { + path: 'login-failed', + component: FailedComponent } ]; diff --git a/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app.module.ts b/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app.module.ts index b7deff03bd..562f4abece 100644 --- a/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app.module.ts +++ b/samples/msal-angular-v2-samples/angular11-sample-app/src/app/app.module.ts @@ -43,7 +43,10 @@ export function MSALInterceptorConfigFactory(): MsalInterceptorConfiguration { } export function MSALGuardConfigFactory(): MsalGuardConfiguration { - return { interactionType: InteractionType.Redirect }; + return { + interactionType: InteractionType.Redirect, + loginFailedRoute: '/login-failed' + }; } @NgModule({ diff --git a/samples/msal-angular-v2-samples/angular11-sample-app/src/app/failed/failed.component.html b/samples/msal-angular-v2-samples/angular11-sample-app/src/app/failed/failed.component.html new file mode 100644 index 0000000000..61effd3a25 --- /dev/null +++ b/samples/msal-angular-v2-samples/angular11-sample-app/src/app/failed/failed.component.html @@ -0,0 +1,3 @@ +

+ Login failed. Please try again. +

diff --git a/samples/msal-angular-v2-samples/angular11-sample-app/src/app/failed/failed.component.ts b/samples/msal-angular-v2-samples/angular11-sample-app/src/app/failed/failed.component.ts new file mode 100644 index 0000000000..70392eb9ec --- /dev/null +++ b/samples/msal-angular-v2-samples/angular11-sample-app/src/app/failed/failed.component.ts @@ -0,0 +1,14 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-orders', + templateUrl: './failed.component.html' +}) +export class FailedComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +}