diff --git a/lib/msal-angular/src/msal.guard.config.ts b/lib/msal-angular/src/msal.guard.config.ts index 6d5c6bab0c..6993da2175 100644 --- a/lib/msal-angular/src/msal.guard.config.ts +++ b/lib/msal-angular/src/msal.guard.config.ts @@ -8,6 +8,7 @@ import { PopupRequest, RedirectRequest, InteractionType, + SilentRequest, } from "@azure/msal-browser"; import { MsalService } from "./msal.service"; @@ -24,4 +25,9 @@ export type MsalGuardConfiguration = { state: RouterStateSnapshot ) => MsalGuardAuthRequest); loginFailedRoute?: string; + enableCheckForExpiredToken?: boolean; + minimumSecondsBeforeTokenExpiration?: number; + silentAuthRequest?: + | SilentRequest + | ((authService: MsalService, state: RouterStateSnapshot) => SilentRequest); }; diff --git a/lib/msal-angular/src/msal.guard.spec.ts b/lib/msal-angular/src/msal.guard.spec.ts index 4f4087862f..ceda933a1f 100644 --- a/lib/msal-angular/src/msal.guard.spec.ts +++ b/lib/msal-angular/src/msal.guard.spec.ts @@ -4,6 +4,8 @@ import { UrlTree } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { Location } from "@angular/common"; import { + AccountInfo, + AuthenticationResult, BrowserSystemOptions, InteractionType, IPublicClientApplication, @@ -28,6 +30,9 @@ let testInteractionType: InteractionType; let testLoginFailedRoute: string; let testConfiguration: Partial; let browserSystemOptions: BrowserSystemOptions; +let enableCheckForExpiredToken: boolean | undefined; +let minimumSecondsBeforeTokenExpiration: number | undefined; +let silentAuthRequest: any; function MSALInstanceFactory(): IPublicClientApplication { return new PublicClientApplication({ @@ -53,6 +58,20 @@ function MSALGuardConfigFactory(): MsalGuardConfiguration { interactionType: testInteractionType, loginFailedRoute: testLoginFailedRoute, authRequest: testConfiguration?.authRequest, + ...(enableCheckForExpiredToken === undefined + ? {} // when enableCheckForExpiredToken is undefined we do not change the returned config object. Important to test backward compatibility when property is not present + : { enableCheckForExpiredToken: enableCheckForExpiredToken }), + ...(minimumSecondsBeforeTokenExpiration === undefined + ? {} // when minimumSecondsBeforeTokenExpiration is undefined we do not change the returned config object. Important to test backward compatibility when property is not present + : { + minimumSecondsBeforeTokenExpiration: + minimumSecondsBeforeTokenExpiration, + }), + ...(silentAuthRequest === undefined + ? {} // when minimumSecondsBeforeTokenExpiration is undefined we do not change the returned config object. Important to test backward compatibility when property is not present + : { + silentAuthRequest: silentAuthRequest, + }), }; } @@ -80,6 +99,8 @@ describe("MsalGuard", () => { testLoginFailedRoute = undefined; testConfiguration = {}; browserSystemOptions = {}; + enableCheckForExpiredToken = undefined; + minimumSecondsBeforeTokenExpiration = undefined; routeStateMock = { snapshot: {}, url: "/" }; initializeMsal(); }); @@ -365,6 +386,126 @@ describe("MsalGuard", () => { }); }); + it("returns false for option enableCheckForExpiredToken is true and token is expired and silentRefresh fails", (done) => { + enableCheckForExpiredToken = true; + initializeMsal(); + + authService.handleRedirectObservable().subscribe(); + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn( + PublicClientApplication.prototype, + "getActiveAccount" + ).and.returnValue({ + idTokenClaims: { + exp: Math.round(Date.now() / 1000) - 10, // set expiration claim to now + 10 secs + }, + } as AccountInfo); + + spyOn(MsalService.prototype, "acquireTokenSilent").and.returnValue( + of({ accessToken: undefined } as AuthenticationResult) + ); + + guard.canActivate(routeMock, routeStateMock).subscribe((result) => { + expect(result).toBeFalse(); + done(); + }); + }); + + it("returns false for option enableCheckForExpiredToken is true and token is not expired but not within minimumSecondsBeforeTokenExpiration and silentRefresh fails", (done) => { + enableCheckForExpiredToken = true; + minimumSecondsBeforeTokenExpiration = 60; + initializeMsal(); + + authService.handleRedirectObservable().subscribe(); + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn( + PublicClientApplication.prototype, + "getActiveAccount" + ).and.returnValue({ + idTokenClaims: { + exp: Math.round(Date.now() / 1000) + 30, // set expiration claim to now + 30 secs + }, + } as AccountInfo); + + spyOn(MsalService.prototype, "acquireTokenSilent").and.returnValue( + of({ accessToken: undefined } as AuthenticationResult) + ); + + guard.canActivate(routeMock, routeStateMock).subscribe((result) => { + expect(result).toBeFalse(); + done(); + }); + }); + + it("returns true for option enableCheckForExpiredToken is true and token is expired and silentRefresh succeeds", (done) => { + enableCheckForExpiredToken = true; + silentAuthRequest = {}; // set silentauth request or it will not be tried + initializeMsal(); + + authService.handleRedirectObservable().subscribe(); + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn( + PublicClientApplication.prototype, + "getActiveAccount" + ).and.returnValue({ + idTokenClaims: { + exp: Math.round(Date.now() / 1000) - 10, // set expiration claim to now + 10 secs + }, + } as AccountInfo); + + spyOn(MsalService.prototype, "acquireTokenSilent").and.returnValue( + of({ accessToken: "validToken" } as AuthenticationResult) + ); + + guard.canActivate(routeMock, routeStateMock).subscribe((result) => { + expect(result).toBeTrue(); + done(); + }); + }); + + it("returns true for option enableCheckForExpiredToken is true and token is not expired", (done) => { + enableCheckForExpiredToken = true; + initializeMsal(); + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn( + PublicClientApplication.prototype, + "getActiveAccount" + ).and.returnValue({ + idTokenClaims: { + exp: Math.round(Date.now() / 1000) + 60, // set expiration claim to now + 10 secs + }, + } as AccountInfo); + + // spyOn(MsalService.prototype, "acquireTokenSilent").and.returnValue( + // of({ accessToken: "validToken" } as AuthenticationResult) + // ); + + guard.canActivate(routeMock, routeStateMock).subscribe((result) => { + expect(result).toBeTrue(); + done(); + }); + }); + it("should return true after logging in with popup", (done) => { testConfiguration = { authRequest: (authService, state) => { diff --git a/lib/msal-angular/src/msal.guard.ts b/lib/msal-angular/src/msal.guard.ts index f5fa85905d..e4cb9fddd9 100644 --- a/lib/msal-angular/src/msal.guard.ts +++ b/lib/msal-angular/src/msal.guard.ts @@ -19,9 +19,10 @@ import { PopupRequest, RedirectRequest, AuthenticationResult, + InteractionStatus, } from "@azure/msal-browser"; import { Observable, of } from "rxjs"; -import { concatMap, catchError, map } from "rxjs/operators"; +import { concatMap, catchError, map, filter, take } from "rxjs/operators"; import { MsalService } from "./msal.service"; import { MsalGuardConfiguration } from "./msal.guard.config"; import { MsalBroadcastService } from "./msal.broadcast.service"; @@ -177,7 +178,16 @@ export class MsalGuard { return this.authService.handleRedirectObservable(); }), concatMap(() => { - if (!this.authService.instance.getAllAccounts().length) { + if (!this.msalGuardConfig.enableCheckForExpiredToken) { + return of(!this.authService.instance.getAllAccounts().length); + } else { + return this.isTokenStillValidForActiveAccountRefreshIfPossible( + state + ).pipe(map((validToken) => !validToken)); + } + }), + concatMap((requireLogin) => { + if (requireLogin) { if (state) { this.authService .getLogger() @@ -192,9 +202,19 @@ export class MsalGuard { return of(false); } - this.authService - .getLogger() - .verbose("Guard - at least 1 account exists, can activate or load"); + if (!this.msalGuardConfig.enableCheckForExpiredToken) { + this.authService + .getLogger() + .verbose("Guard - at least 1 account exists, can activate or load"); + } + + if (!!this.msalGuardConfig.enableCheckForExpiredToken) { + this.authService + .getLogger() + .verbose( + "Guard - active account has a valid token, can activate or load" + ); + } // Prevent navigating the app to /#code= or /code= if (state) { @@ -287,4 +307,66 @@ export class MsalGuard { this.authService.getLogger().verbose("Guard - canLoad"); return this.activateHelper(); } + + /* + * will return false if no active account or token expired and silent refresh failed + * will return true if we have a non expired token + */ + isTokenStillValidForActiveAccountRefreshIfPossible( + state: RouterStateSnapshot + ): Observable { + const activeAccount = this.authService.instance.getActiveAccount(); + + if (!activeAccount) { + return of(false); + } + + const now = Math.round(Date.now() / 1000); + const expiration = activeAccount.idTokenClaims?.["exp"]; + + const expired = + now + (this.msalGuardConfig.minimumSecondsBeforeTokenExpiration ?? 0) > + expiration; + + if (!expired) { + return of(true); + } else { + if (!this.msalGuardConfig.silentAuthRequest) { + return of(false); + } + this.authService + .getLogger() + .info( + "Guard - token for active account is expired. Initiating silent refresh" + ); + const silentRequest = + typeof this.msalGuardConfig.silentAuthRequest === "function" + ? this.msalGuardConfig.silentAuthRequest(this.authService, state) + : { ...this.msalGuardConfig.silentAuthRequest }; + + return this.msalBroadcastService.inProgress$.pipe( + filter( + (status: InteractionStatus) => status === InteractionStatus.None + ), + take(1), + concatMap(() => { + return this.authService.acquireTokenSilent(silentRequest); + }), + map((authResult) => { + this.authService.getLogger().info("Guard - silent refresh succeeded"); + return !!authResult.accessToken; + }), + catchError((err) => { + this.authService + .getLogger() + .warning( + `Guard - silent refresh failed. Reporting no valid token. error: ${JSON.stringify( + err + )}` + ); + return of(false); + }) + ); + } + } }