diff --git a/lib/msal-angular/docs/msal-interceptor.md b/lib/msal-angular/docs/msal-interceptor.md index ce94fc8e04..af33d372d3 100644 --- a/lib/msal-angular/docs/msal-interceptor.md +++ b/lib/msal-angular/docs/msal-interceptor.md @@ -4,51 +4,51 @@ MSAL Angular provides an `Interceptor` class that automatically acquires tokens While we recommend using the `MsalInterceptor` instead of the `acquireTokenSilent` API directly, please note that using the `MsalInterceptor` is optional. You may wish to explicitly acquire tokens using the acquireToken APIs instead. -Please note that the `MsalInterceptor` is provided for your convenience and may not fit all use cases. We encourage you to write your own interceptor if you have specific needs that are not addressed by the `MsalInterceptor`. +Please note that the `MsalInterceptor` is provided for your convenience and may not fit all use cases. We encourage you to write your own interceptor if you have specific needs that are not addressed by the `MsalInterceptor`. ## Configuration -### Configuring the `MsalInterceptor` in the *app.module.ts* +### Configuring the `MsalInterceptor` in the _app.module.ts_ -The `MsalInterceptor` can be added to your application as a provider in the *app.module.ts*, with its configuration. The imports takes in an instance of MSAL, as well as two Angular-specific configuration objects. The third argument is a [`MsalInterceptorConfiguration`](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/src/msal.interceptor.config.ts) object, which contain the values for `interactionType`, a `protectedResourceMap`, and an optional `authRequest`. +The `MsalInterceptor` can be added to your application as a provider in the _app.module.ts_, with its configuration. The imports takes in an instance of MSAL, as well as two Angular-specific configuration objects. The third argument is a [`MsalInterceptorConfiguration`](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/src/msal.interceptor.config.ts) object, which contain the values for `interactionType`, a `protectedResourceMap`, and an optional `authRequest`. -Your configuration may look like the below. See our [configuration doc](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/docs/configuration.md) on other ways to configure MSAL Angular for your app. +Your configuration may look like the below. See our [configuration doc](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/docs/configuration.md) on other ways to configure MSAL Angular for your app. ```javascript -import { NgModule } from '@angular/core'; +import { NgModule } from "@angular/core"; import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http"; -import { AppComponent } from './app.component'; -import { MsalModule, MsalRedirectComponent, MsalGuard, MsalInterceptor } from '@azure/msal-angular'; // Import MsalInterceptor -import { InteractionType, PublicClientApplication } from '@azure/msal-browser'; +import { AppComponent } from "./app.component"; +import { MsalModule, MsalRedirectComponent, MsalGuard, MsalInterceptor } from "@azure/msal-angular"; // Import MsalInterceptor +import { InteractionType, PublicClientApplication } from "@azure/msal-browser"; @NgModule({ - declarations: [ - AppComponent, - ], - imports: [ - MsalModule.forRoot( new PublicClientApplication({ - // MSAL Configuration - }), { - // MSAL Guard Configuration - }, { - // MSAL Interceptor Configurations - interactionType: InteractionType.Redirect, - protectedResourceMap: new Map([ - ['Enter_the_Graph_Endpoint_Here/v1.0/me', ['user.read']] - ]) - }) - ], - providers: [ - { - provide: HTTP_INTERCEPTORS, // Provides as HTTP Interceptor - useClass: MsalInterceptor, - multi: true - }, - MsalGuard - ], - bootstrap: [AppComponent, MsalRedirectComponent] + declarations: [AppComponent], + imports: [ + MsalModule.forRoot( + new PublicClientApplication({ + // MSAL Configuration + }), + { + // MSAL Guard Configuration + }, + { + // MSAL Interceptor Configurations + interactionType: InteractionType.Redirect, + protectedResourceMap: new Map([["Enter_the_Graph_Endpoint_Here/v1.0/me", ["user.read"]]]), + } + ), + ], + providers: [ + { + provide: HTTP_INTERCEPTORS, // Provides as HTTP Interceptor + useClass: MsalInterceptor, + multi: true, + }, + MsalGuard, + ], + bootstrap: [AppComponent, MsalRedirectComponent], }) -export class AppModule { } +export class AppModule {} ``` ### Interaction Type @@ -58,7 +58,7 @@ While the `MsalInterceptor` is designed to acquire tokens silently, in the event ```javascript { interactionType: InteractionType.Redirect, - protectedResourceMap: new Map([ + protectedResourceMap: new Map([ ['Enter_the_Graph_Endpoint_Here/v1.0/me', ['user.read']] ]) } @@ -66,86 +66,102 @@ While the `MsalInterceptor` is designed to acquire tokens silently, in the event ### Protected Resource Map -The protected resources and corresponding scopes are provided as a `protectedResourceMap` in the `MsalInterceptor` configuration. +The protected resources and corresponding scopes are provided as a `protectedResourceMap` in the `MsalInterceptor` configuration. The URLs you provide in the `protectedResourceMap` collection are case-sensitive. For each resource, add scopes being requested to be returned in the access token. For example: -* `["user.read"]` for Microsoft Graph -* `["/scope"]` for custom web APIs (that is, `api:///access_as_user`) - +- `["user.read"]` for Microsoft Graph +- `["/scope"]` for custom web APIs (that is, `api:///access_as_user`) Scopes can be specified for a resource in the following ways: 1. An array of scopes, which will be added to every HTTP request to that resource, regardless of HTTP method. - ```javascript - { - interactionType: InteractionType.Redirect, - protectedResourceMap: new Map | null>([ - ["https://graph.microsoft.com/v1.0/me", ["user.read", "profile"]], - ["https://myapplication.com/user/*", ["customscope.read"]] - ]), - } - ``` - -1. An array of `ProtectedResourceScopes`, which will attach scopes only for specific HTTP methods. - - ```javascript - { - interactionType: InteractionType.Redirect, - protectedResourceMap: new Map | null>([ - ["https://graph.microsoft.com/v1.0/me", ["user.read"]], - ["http://myapplication.com", [ - { - httpMethod: "POST", - scopes: ["write.scope"] - } - ]] - ]) - } - ``` - - Note that scopes for a resource can contain a combination of strings and `ProtectedResourceScopes`. In the below example, a `GET` request will have the scopes `"all.scope"` and `"read.scope"`, whereas as `PUT` request would just have `"all.scope"`. - - ```javascript - { - interactionType: InteractionType.Redirect, - protectedResourceMap: new Map | null>([ - ["http://myapplication.com", [ - "all.scope", - { - httpMethod: "GET", - scopes: ["read.scope"] - }, - { - httpMethod: "POST", - scopes: ["info.scope"] - } - ]] - ]) - } - ``` + ```javascript + { + interactionType: InteractionType.Redirect, + protectedResourceMap: new Map | null>([ + ["https://graph.microsoft.com/v1.0/me", ["user.read", "profile"]], + ["https://myapplication.com/user/*", ["customscope.read"]] + ]), + } + ``` + +1. An array of `ProtectedResourceScopes`, which will attach scopes only for specific HTTP methods. + + ```javascript + { + interactionType: InteractionType.Redirect, + protectedResourceMap: new Map | null>([ + ["https://graph.microsoft.com/v1.0/me", ["user.read"]], + ["http://myapplication.com", [ + { + httpMethod: "POST", + scopes: ["write.scope"] + } + ]] + ]) + } + ``` + + Note that scopes for a resource can contain a combination of strings and `ProtectedResourceScopes`. In the below example, a `GET` request will have the scopes `"all.scope"` and `"read.scope"`, whereas as `PUT` request would just have `"all.scope"`. + + ```javascript + { + interactionType: InteractionType.Redirect, + protectedResourceMap: new Map | null>([ + ["http://myapplication.com", [ + "all.scope", + { + httpMethod: "GET", + scopes: ["read.scope"] + }, + { + httpMethod: "POST", + scopes: ["info.scope"] + } + ]] + ]) + } + ``` 1. A scope value of `null`, indicating that a resource is to be unprotected and will not get tokens. Resources not included in the `protectedResourceMap` are not protected by default. Specifying a particular resource to be unprotected can be useful when some routes on a resource are to be protected, and some are not. Note that the order in `protectedResourceMap` matters, so null resource should be put before any similar base urls or wildcards. - ```javascript - { - interactionType: InteractionType.Redirect, - protectedResourceMap: new Map | null>([ - ["https://graph.microsoft.com/v1.0/me", ["user.read", "profile"]], - ["https://myapplication.com/unprotected", null], - ["https://myapplication.com/unprotected/post", [{ httpMethod: 'POST', scopes: null }]], - ["https://myapplication.com", ["custom.scope"]] - ]), - } - ``` + ```javascript + { + interactionType: InteractionType.Redirect, + protectedResourceMap: new Map | null>([ + ["https://graph.microsoft.com/v1.0/me", ["user.read", "profile"]], + ["https://myapplication.com/unprotected", null], + ["https://myapplication.com/unprotected/post", [{ httpMethod: 'POST', scopes: null }]], + ["https://myapplication.com", ["custom.scope"]] + ]), + } + ``` Other things to note regarding the `protectedResourceMap`: -* **Wildcards**: `protectedResourceMap` supports using `*` for wildcards. When using wildcards, if multiple matching entries are found in the `protectedResourceMap`, the first match found will be used (based on the order of the `protectedResourceMap`). -* **Relative paths**: If there are relative resource paths in your application, you may need to provide the relative path in the `protectedResourceMap`. This also applies to issues that may arise with ngx-translate. Be aware that the relative path in your `protectedResourceMap` may or may not need a leading slash depending on your app, and may need to try both. +- **Wildcards**: `protectedResourceMap` supports using `*` for wildcards. When using wildcards, if multiple matching entries are found in the `protectedResourceMap`, the first match found will be used (based on the order of the `protectedResourceMap`). +- **Relative paths**: If there are relative resource paths in your application, you may need to provide the relative path in the `protectedResourceMap`. This also applies to issues that may arise with ngx-translate. Be aware that the relative path in your `protectedResourceMap` may or may not need a leading slash depending on your app, and may need to try both. + +#### Optional Token Attachment + +If you have a resource that both supports anonymous and authenticated requests, and only want to attach a token when a user has logged in, you can specify the `ProtectedResourceScopes` as optional. In the example below, all `GET` requests to the https://myapplication.com/projects endpoint will attach a token if a user is logged in, otherwise will send the request without a token. It will not prompt the user to login. However, if an unauthenticated user tries to `POST` to the endpoint, they will be required to authenticate so the appropriate token can be sent with the request. + +```javascript +{ + interactionType: InteractionType.Redirect, + protectedResourceMap: new Map | null>([ + ["https://graph.microsoft.com/v1.0/me", ["user.read", "profile"]], + ["https://myapplication.com/projects", [ + { httpMethod: 'GET', scopes: ['projects.read'], optional: true } + { httpMetohd: 'POST', scopes: ['projects.write']} + ]], + ]), +} +``` ### Optional authRequest @@ -153,8 +169,8 @@ For more information on the optional `authRequest` that can be set in the `MsalI ## Changes from msal-angular v1 to v2 -* Note that the `unprotectedResourceMap` in MSAL Angular v1's `MsalAngularConfiguration` has been deprecated and no longer works. -* `protectedResourceMap` has been moved to the `MsalInterceptorConfiguration` object, and can be passed as `Map>`. `MsalAngularConfiguration` has been deprecated and no longer works. -* Putting the root domain in the `protectedResourceMap` to protect all routes is no longer supported. Please use wildcard matching instead. +- Note that the `unprotectedResourceMap` in MSAL Angular v1's `MsalAngularConfiguration` has been deprecated and no longer works. +- `protectedResourceMap` has been moved to the `MsalInterceptorConfiguration` object, and can be passed as `Map>`. `MsalAngularConfiguration` has been deprecated and no longer works. +- Putting the root domain in the `protectedResourceMap` to protect all routes is no longer supported. Please use wildcard matching instead. -For more information on how to configure scopes, please see our [FAQs](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/FAQ.md). +For more information on how to configure scopes, please see our [FAQs](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/FAQ.md). diff --git a/lib/msal-angular/src/msal.interceptor.config.ts b/lib/msal-angular/src/msal.interceptor.config.ts index e899a81cb0..63b578b363 100644 --- a/lib/msal-angular/src/msal.interceptor.config.ts +++ b/lib/msal-angular/src/msal.interceptor.config.ts @@ -36,6 +36,7 @@ export type MsalInterceptorConfiguration = { export type ProtectedResourceScopes = { httpMethod: string; scopes: Array | null; + optional?: boolean; }; export type MatchingResources = { diff --git a/lib/msal-angular/src/msal.interceptor.spec.ts b/lib/msal-angular/src/msal.interceptor.spec.ts index 02ca4c256a..a72e8a3153 100644 --- a/lib/msal-angular/src/msal.interceptor.spec.ts +++ b/lib/msal-angular/src/msal.interceptor.spec.ts @@ -97,6 +97,25 @@ function MSALInterceptorFactory(): MsalInterceptorConfiguration { ], ["http://applicationE.com/profile/", ["customE.scope"]], ["http://applicationF.com/profile/", ["customF.scope"]], + [ + "http://applicationG.com", + [ + { + httpMethod: "GET", + scopes: ["read.scope"], + optional: true, + }, + { + httpMethod: "POST", + scopes: ["write.scope"], + optional: false, + }, + { + httpMethod: "PUT", + scopes: ["write.scope"], + }, + ], + ], ]), authRequest: testInterceptorConfig.authRequest, }; @@ -1122,4 +1141,115 @@ describe("MsalInterceptor", () => { done(); }, 200); }); + + it("does not attach authorization header when specific http method set to optional and no active user account", (done) => { + httpClient + .get("http://applicationG.com") + .subscribe((response) => expect(response).toBeTruthy()); + + const request = httpMock.expectOne("http://applicationG.com"); + request.flush({ data: "test" }); + expect(request.request.headers.get("Authorization")).toBeUndefined; + httpMock.verify(); + done(); + }); + + it("attaches authorization header when specific http method set to optional and active user account", (done) => { + const spy = spyOn( + PublicClientApplication.prototype, + "acquireTokenSilent" + ).and.returnValue( + new Promise((resolve) => { + //@ts-ignore + resolve({ + accessToken: "access-token", + }); + }) + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + sampleAccountInfo, + ]); + + httpClient.get("http://applicationG.com").subscribe(); + setTimeout(() => { + const request = httpMock.expectOne("http://applicationG.com"); + request.flush({ data: "test" }); + expect(request.request.headers.get("Authorization")).toEqual( + "Bearer access-token" + ); + expect(spy).toHaveBeenCalledWith({ + account: sampleAccountInfo, + scopes: ["read.scope"], + }); + httpMock.verify(); + done(); + }, 200); + }); + + it("attaches authorization header when specific http method explicitly required and active user account", (done) => { + const spy = spyOn( + PublicClientApplication.prototype, + "acquireTokenSilent" + ).and.returnValue( + new Promise((resolve) => { + //@ts-ignore + resolve({ + accessToken: "access-token", + }); + }) + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + sampleAccountInfo, + ]); + + httpClient.post("http://applicationG.com", {}).subscribe(); + setTimeout(() => { + const request = httpMock.expectOne("http://applicationG.com"); + request.flush({ data: "test" }); + expect(request.request.headers.get("Authorization")).toEqual( + "Bearer access-token" + ); + expect(spy).toHaveBeenCalledWith({ + account: sampleAccountInfo, + scopes: ["write.scope"], + }); + httpMock.verify(); + done(); + }, 200); + }); + + it("attaches authorization header when specific http method implcitly required and active user account", (done) => { + const spy = spyOn( + PublicClientApplication.prototype, + "acquireTokenSilent" + ).and.returnValue( + new Promise((resolve) => { + //@ts-ignore + resolve({ + accessToken: "access-token", + }); + }) + ); + + spyOn(PublicClientApplication.prototype, "getAllAccounts").and.returnValue([ + sampleAccountInfo, + ]); + + httpClient.put("http://applicationG.com", {}).subscribe(); + setTimeout(() => { + const request = httpMock.expectOne("http://applicationG.com"); + request.flush({ data: "test" }); + expect(request.request.headers.get("Authorization")).toEqual( + "Bearer access-token" + ); + expect(spy).toHaveBeenCalledWith({ + account: sampleAccountInfo, + scopes: ["write.scope"], + }); + httpMock.verify(); + done(); + }, 200); + }); }); diff --git a/lib/msal-angular/src/msal.interceptor.ts b/lib/msal-angular/src/msal.interceptor.ts index 28051c7384..d365bfd332 100644 --- a/lib/msal-angular/src/msal.interceptor.ts +++ b/lib/msal-angular/src/msal.interceptor.ts @@ -75,18 +75,7 @@ export class MsalInterceptor implements HttpInterceptor { } // Sets account as active account or first account - let account: AccountInfo; - if (!!this.authService.instance.getActiveAccount()) { - this.authService - .getLogger() - .verbose("Interceptor - active account selected"); - account = this.authService.instance.getActiveAccount(); - } else { - this.authService - .getLogger() - .verbose("Interceptor - no active account, fallback to first account"); - account = this.authService.instance.getAllAccounts()[0]; - } + const account = this.getActiveOrFirstAcount(); const authRequest = typeof this.msalInterceptorConfig.authRequest === "function" @@ -334,6 +323,8 @@ export class MsalInterceptor implements HttpInterceptor { ): Array | null { const allMatchedScopes = []; + const account = this.getActiveOrFirstAcount(); + // Check each matched endpoint for matching HttpMethod and scopes endpointArray.forEach((matchedEndpoint) => { const scopesForEndpoint = []; @@ -353,11 +344,15 @@ export class MsalInterceptor implements HttpInterceptor { // Ensure methods being compared are normalized const normalizedRequestMethod = httpMethod.toLowerCase(); const normalizedResourceMethod = entry.httpMethod.toLowerCase(); + // Method in protectedResourceMap matches request http method if (normalizedResourceMethod === normalizedRequestMethod) { // Validate if scopes comes null to unprotect the resource in a certain http method if (entry.scopes === null) { allMatchedScopes.push(null); + } else if (!account && !!entry.optional) { + // If there's no account, and the entry is optional we don't need to send a token - no scopes are required + allMatchedScopes.push(null); } else { entry.scopes.forEach((scope) => { scopesForEndpoint.push(scope); @@ -387,4 +382,26 @@ export class MsalInterceptor implements HttpInterceptor { return null; } + + /** + * Gets the active Account. If there is no active account will return the first account + * @returns Active or first account + */ + private getActiveOrFirstAcount(): AccountInfo { + let account: AccountInfo; + + if (!!this.authService.instance.getActiveAccount()) { + this.authService + .getLogger() + .verbose("Interceptor - active account selected"); + account = this.authService.instance.getActiveAccount(); + } else { + this.authService + .getLogger() + .verbose("Interceptor - no active account, fallback to first account"); + account = this.authService.instance.getAllAccounts()[0]; + } + + return account; + } } diff --git a/package-lock.json b/package-lock.json index c75f07675b..3b31ae0eb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "extensions/msal-node-extensions": { "name": "@azure/msal-node-extensions", "version": "1.0.12", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@azure/msal-common": "14.7.1",