Skip to content

Commit

Permalink
feature/msal-angular/msal-guard-stricter: adding feature enable check…
Browse files Browse the repository at this point in the history
… for token expiration
  • Loading branch information
alexgoeman committed Jul 4, 2024
1 parent be05b05 commit 8752a84
Show file tree
Hide file tree
Showing 3 changed files with 234 additions and 5 deletions.
6 changes: 6 additions & 0 deletions lib/msal-angular/src/msal.guard.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
PopupRequest,
RedirectRequest,
InteractionType,
SilentRequest,
} from "@azure/msal-browser";
import { MsalService } from "./msal.service";

Expand All @@ -24,4 +25,9 @@ export type MsalGuardConfiguration = {
state: RouterStateSnapshot
) => MsalGuardAuthRequest);
loginFailedRoute?: string;
enableCheckForExpiredToken?: boolean;
minimumSecondsBeforeTokenExpiration?: number;
silentAuthRequest?:
| SilentRequest
| ((authService: MsalService, state: RouterStateSnapshot) => SilentRequest);
};
141 changes: 141 additions & 0 deletions lib/msal-angular/src/msal.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +30,9 @@ let testInteractionType: InteractionType;
let testLoginFailedRoute: string;
let testConfiguration: Partial<MsalGuardConfiguration>;
let browserSystemOptions: BrowserSystemOptions;
let enableCheckForExpiredToken: boolean | undefined;
let minimumSecondsBeforeTokenExpiration: number | undefined;
let silentAuthRequest: any;

function MSALInstanceFactory(): IPublicClientApplication {
return new PublicClientApplication({
Expand All @@ -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,
}),
};
}

Expand Down Expand Up @@ -80,6 +99,8 @@ describe("MsalGuard", () => {
testLoginFailedRoute = undefined;
testConfiguration = {};
browserSystemOptions = {};
enableCheckForExpiredToken = undefined;
minimumSecondsBeforeTokenExpiration = undefined;
routeStateMock = { snapshot: {}, url: "/" };
initializeMsal();
});
Expand Down Expand Up @@ -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) => {
Expand Down
92 changes: 87 additions & 5 deletions lib/msal-angular/src/msal.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -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<boolean> {
const activeAccount = this.authService.instance.getActiveAccount();

if (!activeAccount) {
return of(false);
}

const now = Math.round(Date.now() / 1000);
const expiration = <number>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);
})
);
}
}
}

0 comments on commit 8752a84

Please sign in to comment.