diff --git a/lib/msal-angular/src/msal.guard.config.ts b/lib/msal-angular/src/msal.guard.config.ts index 6d5c6bab0c..7a8c27243b 100644 --- a/lib/msal-angular/src/msal.guard.config.ts +++ b/lib/msal-angular/src/msal.guard.config.ts @@ -24,4 +24,5 @@ export type MsalGuardConfiguration = { state: RouterStateSnapshot ) => MsalGuardAuthRequest); loginFailedRoute?: string; + rbacFailedRoute?: string | ((requiredRoles: string[], claimedRoles: string[]) => string); }; diff --git a/lib/msal-angular/src/public-api.ts b/lib/msal-angular/src/public-api.ts index 77efadd3a4..ce96272745 100644 --- a/lib/msal-angular/src/public-api.ts +++ b/lib/msal-angular/src/public-api.ts @@ -11,6 +11,7 @@ export { MsalService } from "./msal.service"; export { IMsalService } from "./IMsalService"; export { MsalGuard } from "./msal.guard"; +export { makeRbacGuard } from './rbac.guard'; export { MsalGuardConfiguration, MsalGuardAuthRequest, diff --git a/lib/msal-angular/src/rbac.guard.spec.ts b/lib/msal-angular/src/rbac.guard.spec.ts new file mode 100644 index 0000000000..6480cd1977 --- /dev/null +++ b/lib/msal-angular/src/rbac.guard.spec.ts @@ -0,0 +1,669 @@ +import { + BrowserSystemOptions, + InteractionType, + PublicClientApplication, + IPublicClientApplication, + LogLevel, + UrlString +} from "@azure/msal-browser"; +import { MsalGuard } from "./msal.guard"; +import { MsalService } from "./msal.service"; +import { MsalGuardConfiguration } from "./msal.guard.config"; +import { TestBed } from "@angular/core/testing"; +import { MsalModule } from "./msal.module"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { MsalBroadcastService } from "./msal.broadcast.service"; +import { makeRbacGuard } from "./public-api"; +import { + CanActivateChildFn, + CanActivateFn, CanMatchFn, + UrlTree +} from "@angular/router"; +import { Observable, of } from "rxjs"; +import { Location } from "@angular/common"; + +let guard: MsalGuard; +let rbacAdminGuard: CanActivateFn & CanActivateChildFn & CanMatchFn; +let rbacRolelessGuard: CanActivateFn & CanActivateChildFn & CanMatchFn; +let authService: MsalService; +let routeMock: any = { snapshot: {} }; +let routeStateMock: any = { snapshot: {}, url: "/" }; +let testInteractionType: InteractionType; +let testLoginFailedRoute: string; +let testConfiguration: Partial; +let browserSystemOptions: BrowserSystemOptions; + +function MSALInstanceFactory(): IPublicClientApplication { + return new PublicClientApplication({ + auth: { + clientId: "b5c2e510-4a17-4feb-b219-e55aa5b74144", + redirectUri: "http://localhost:4200", + }, + system: { + loggerOptions: { + loggerCallback: (level, message) => { + // console.log(message) + }, + logLevel: LogLevel.Verbose, + piiLoggingEnabled: true, + }, + }, + }); +} + +function MSALGuardConfigFactory(): MsalGuardConfiguration { + return { + //@ts-ignore + interactionType: testInteractionType, + loginFailedRoute: testLoginFailedRoute, + authRequest: testConfiguration?.authRequest, + rbacFailedRoute: testConfiguration.rbacFailedRoute, + }; +} + +function initializeMsal(providers: any[] = []) { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + MsalModule.forRoot(MSALInstanceFactory(), MSALGuardConfigFactory(), { + interactionType: InteractionType.Popup, + protectedResourceMap: new Map(), + }), + HttpClientTestingModule, + RouterTestingModule.withRoutes([]), + ], + providers: [MsalGuard, MsalService, MsalBroadcastService, ...providers], + teardown: { destroyAfterEach: false }, + }); + + authService = TestBed.inject(MsalService); + guard = TestBed.inject(MsalGuard); + rbacAdminGuard = makeRbacGuard('admin'); + rbacRolelessGuard = makeRbacGuard(); +} + +function assertMaybeAsync(result: T | Promise | Observable, expectFn: (result: T) => void) { + if (result instanceof Promise) { + result.then(expectFn); + } else if (result instanceof Observable) { + result.subscribe(expectFn); + } else { + expectFn(result); + } +} + +describe('RBAC Guard', () => { + beforeEach(() => { + testInteractionType = InteractionType.Popup; + testLoginFailedRoute = undefined; + testConfiguration = {}; + browserSystemOptions = {}; + routeStateMock = { snapshot: {}, url: "/" }; + initializeMsal(); + }); + + it("is created", () => { + expect(rbacAdminGuard).toBeTruthy(); + }); + + it("denies access to route if user does not have role", (done) => { + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + idTokenClaims: { + roles: [] + }, + }, + ]); + const result = TestBed.runInInjectionContext(() => rbacAdminGuard(routeMock, routeStateMock)); + if (result instanceof Boolean) { + expect(result).toBeFalse(); + done(); + } + if (result instanceof Promise) { + result.then(result => { + expect(result).toBeFalse(); + done(); + }); + } + if (result instanceof Observable) { + result.subscribe(result => { + expect(result).toBeFalse(); + done(); + }); + } + }); + + it("redirects to configured route if user does not have role", (done) => { + testConfiguration.rbacFailedRoute = "/failed"; + initializeMsal(); + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + idTokenClaims: { + roles: [] + }, + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacAdminGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, result => { + expect(result.toString()).toBe("/failed"); + done(); + }) + }); + + it("redirects to configured function route if user does not have role", (done) => { + testConfiguration.rbacFailedRoute = (requiredRoles, claimedRoles) => `/failed/${requiredRoles[0]}`; + initializeMsal(); + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + idTokenClaims: { + roles: [] + }, + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacAdminGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result.toString()).toBe("/failed/admin"); + done(); + }); + }); + + + it("allows access to route if user does have role", (done) => { + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + idTokenClaims: { + roles: ["admin"] + }, + }, + ]); + const result = TestBed.runInInjectionContext(() => rbacAdminGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result).toBeTrue(); + done(); + }); + }); + + it("throws error for silent interaction type", (done) => { + testInteractionType = InteractionType.Silent; + initializeMsal(); + try { + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => {}) + } catch (err) { + expect(err.errorCode).toBe("invalid_interaction_type"); + done(); + } + }); + + it("returns false if page with MSAL Guard is set as redirectUri", (done) => { + spyOn(UrlString, "hashContainsKnownProperties").and.returnValue(true); + spyOnProperty(window, "parent", "get").and.returnValue({ ...window }); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result).toBeFalse(); + done(); + }); + }); + + it("returns false if page contains known successful response (path routing)", (done) => { + initializeMsal([ + { + provide: Location, + useValue: { + path: jasmine + .createSpy("path") + .and.callFake((hash: boolean) => + hash ? "/path?code=123#code=456" : "/path" + ), + prepareExternalUrl: jasmine + .createSpy("prepareExternalUrl") + .and.callFake((url: string) => "/path"), + }, + }, + ]); + + routeStateMock = { + snapshot: {}, + url: "/path?code=123#code=456", + root: { + fragment: "code=456", + }, + }; + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + }, + ]); + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result.toString()).toEqual("/path"); + done(); + }); + }); + + it("returns true if page contains code= in query parameters only", (done) => { + initializeMsal([ + { + provide: Location, + useValue: { + path: jasmine + .createSpy("path") + .and.callFake((hash: boolean) => + hash ? "/path?code=123" : "/path" + ), + prepareExternalUrl: jasmine + .createSpy("prepareExternalUrl") + .and.callFake((url: string) => "/path"), + }, + }, + ]); + + routeStateMock = { + snapshot: {}, + url: "/path?code=123", + root: { + fragment: null, + }, + }; + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result).toBeTrue(); + done(); + }) + }); + + it("returns true if page route doesnt end with /code", (done) => { + initializeMsal([ + { + provide: Location, + useValue: { + path: jasmine + .createSpy("path") + .and.callFake((hash: boolean) => (hash ? "/codes" : "/")), + prepareExternalUrl: jasmine + .createSpy("prepareExternalUrl") + .and.callFake((url: string) => "#/codes"), + }, + }, + ]); + + routeStateMock = { + snapshot: {}, + url: "/codes", + root: { + fragment: null, + }, + }; + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result).toBeTrue(); + done(); + }); + }); + + it("returns true if page route doesnt end with /code (short path)", (done) => { + initializeMsal([ + { + provide: Location, + useValue: { + path: jasmine + .createSpy("path") + .and.callFake((hash: boolean) => (hash ? "/cod" : "/")), + prepareExternalUrl: jasmine + .createSpy("prepareExternalUrl") + .and.callFake((url: string) => "#/cod"), + }, + }, + ]); + + routeStateMock = { + snapshot: {}, + url: "/cod", + root: { + fragment: null, + }, + }; + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + + assertMaybeAsync(result, (result) => { + expect(result).toBeTrue(); + done(); + }); + }); + + it("returns false if page contains known successful response (hash routing)", (done) => { + initializeMsal([ + { + provide: Location, + useValue: { + path: jasmine + .createSpy("path") + .and.callFake((hash: boolean) => (hash ? "/code=" : "/")), + prepareExternalUrl: jasmine + .createSpy("prepareExternalUrl") + .and.callFake((url: string) => "#/"), + }, + }, + ]); + + routeStateMock = { + snapshot: {}, + url: "/code", + root: { + fragment: null, + }, + }; + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + + assertMaybeAsync(result, (result) => { + expect(result.toString()).toEqual("/"); + done(); + }); + }); + + it("returns true for a logged in user", (done) => { + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result).toBeTrue(); + done(); + }); + }); + + it("should return true after logging in with popup", (done) => { + testConfiguration = { + authRequest: (authService, state) => { + expect(state).toBeDefined(); + expect(authService).toBeDefined(); + return {}; + }, + }; + initializeMsal(); + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue( + [] + ); + + spyOn(MsalService.prototype, "loginPopup").and.returnValue( + //@ts-ignore + of(true) + ); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + + assertMaybeAsync(result, (result) => { + expect(result).toBeTrue(); + 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") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue( + [] + ); + + spyOn(MsalService.prototype, "loginPopup").and.throwError("login error"); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result).toBeFalse(); + 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 result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + + assertMaybeAsync(result, (result) => { + expect(result).toEqual("failed" as unknown as UrlTree); + done(); + }); + + }); + + it("should return false after logging in with redirect", (done) => { + testInteractionType = InteractionType.Redirect; + initializeMsal(); + + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue( + [] + ); + + spyOn(PublicClientApplication.prototype, "loginRedirect").and.returnValue( + new Promise((resolve) => { + resolve(); + }) + ); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + + assertMaybeAsync(result, (result) => { + expect(result).toBeFalse(); + done(); + }) + + }); + + it("canActivateChild returns true with logged in user", (done) => { + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard(routeMock, routeStateMock)); + assertMaybeAsync(result, (result) => { + expect(result).toBeTrue(); + done(); + }); + }); + + it("canLoad returns true with logged in user", (done) => { + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + { + homeAccountId: "test", + localAccountId: "test", + environment: "test", + tenantId: "test", + username: "test", + }, + ]); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard({} as any, [])); + assertMaybeAsync(result, (result) => { + expect(result).toBeTrue(); + done(); + }); + }); + + it("canLoad returns false with no users logged in", (done) => { + spyOn(MsalService.prototype, "handleRedirectObservable").and.returnValue( + //@ts-ignore + of("test") + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue( + [] + ); + + const result = TestBed.runInInjectionContext(() => rbacRolelessGuard({} as any, [])); + assertMaybeAsync(result, (result) => { + expect(result).toBeFalse(); + done(); + }); + }); +}) diff --git a/lib/msal-angular/src/rbac.guard.ts b/lib/msal-angular/src/rbac.guard.ts new file mode 100644 index 0000000000..f8850e3d8c --- /dev/null +++ b/lib/msal-angular/src/rbac.guard.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateChildFn, + CanActivateFn, + CanMatchFn, + Route, + Router, + RouterStateSnapshot, + UrlSegment, + UrlTree +} from '@angular/router'; + +import { AccountInfo } from '@azure/msal-browser'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { MsalService } from './msal.service'; +import { MsalGuard } from './msal.guard'; +import { MsalGuardConfiguration } from './msal.guard.config'; +import { MSAL_GUARD_CONFIG } from './constants'; + +function getAccount(msalService: MsalService): AccountInfo | null { + let account: AccountInfo | null | undefined = msalService.instance.getActiveAccount(); + if (account) { + return account; + } + account = msalService.instance.getAllAccounts().values().next().value; + if (account) { + return account; + } + msalService.getLogger() + .error("RBAC Guard - no accounts retrieved"); + return null; +} + +function enforceRbac(router: Router, msalGuardConfig: MsalGuardConfiguration, msalService: MsalService, requiredRoles: string[]): boolean | UrlTree { + if (requiredRoles.length === 0) { + return true; + } + const account = getAccount(msalService); + if (account === null) { + return false; + } + let rbacFailedRoute: UrlTree | undefined; + switch (typeof(msalGuardConfig.rbacFailedRoute)) { + case 'string': + rbacFailedRoute = router.parseUrl(msalGuardConfig.rbacFailedRoute); + break; + case 'function': + rbacFailedRoute = router.parseUrl(msalGuardConfig.rbacFailedRoute(requiredRoles, account.idTokenClaims?.roles ?? [])); + break; + default: + rbacFailedRoute = undefined; + } + const hasRequiredRoles = requiredRoles.every(requiredRole => account.idTokenClaims?.roles?.includes(requiredRole) ?? false); + if (!hasRequiredRoles && rbacFailedRoute) { + return rbacFailedRoute; + } + return hasRequiredRoles; +} + +/** + * Invokes MsalGuard to require user authentication, then verifies their ID token contains + * all the roles provided + * @param roles - a list of roles required to access this route + */ +export function makeRbacGuard(...roles: string[]): CanActivateFn & CanActivateChildFn & CanMatchFn { + return (route: ActivatedRouteSnapshot | Route, stateOrSegments: RouterStateSnapshot | UrlSegment[]): Observable => { + const router = inject(Router); + const msalGuard = inject(MsalGuard); + const msalService = inject(MsalService); + const msalGuardConfig = inject(MSAL_GUARD_CONFIG); + let msalGuardResult: Observable; + if (!Array.isArray(stateOrSegments)) { + /* + * note: when invoked as canActivateChild we still call canActivate + * as the `CanActivateFn` and `CanActivateChildFn` interfaces are identical + * and cannot be disambiguated at runtime. This is acceptable, as the only difference + * between the two methods implemented in `@azure/msal-angular` is a single log message. + */ + msalGuardResult = msalGuard.canActivate(route as ActivatedRouteSnapshot, stateOrSegments as RouterStateSnapshot); + } else { + msalGuardResult = msalGuard.canMatch(); + } + + return msalGuardResult.pipe( + map(result => result === true ? enforceRbac(router, msalGuardConfig, msalService, roles) : result) + ); + } +}