'Angular 13 MSAL 2.0 & .NET core API: Bearer error="invalid_token", error_description="The signature is invalid"

To isolate the problem, I have created the famous Visual Studio default "weather forecast" .NET core project with angular and tried to make the Angular ClientApp authenticate using a bearer token.

I have created a similar project two years ago with MSAL 1.0 which works just fine. I am using the same app registration, authority etc. as in this project. I get a token, send it to the API and this is what I get in the response header:

Bearer error="invalid_token", error_description="The signature is invalid"

When I check the token at jwt.io, I indeed see that there is a validation error in the signature as well, I also compared the tokens from MSAL v1 and MSAL v2, they pretty much look the same apart from v1 containing groups": ["cc...d1"] and "roles": ["MyAdminRole"] (which I also need to find out how to get these in a token from v2). The token from v1 validates without any problems.

In Postman if I try to send the bearer token I got from my productive v1 app to my test API, it also works without any problems which convinces me that something goes wrong in the process of obtaining the token.

The code I mainly took from the angular13-rxjs7-sample-app on GitHub. I am running it locally in VS2022 with @azure/[email protected]. This is how it looks like:

app.module.ts:

    export function loggerCallback(logLevel: LogLevel, message: string) {
      console.log(message);
    }
    
    export function tokenGetter() {
      return localStorage.getItem("jwt");
    }
    
    export function MSALInstanceFactory(): IPublicClientApplication {
      return new PublicClientApplication({
        auth: {
          clientId: environment.clientID, // PPE testing environment
          authority: environment.authority, // PPE testing environment.
          redirectUri: environment.redirectUri,
          postLogoutRedirectUri: '/'
        },
        cache: {
          cacheLocation: BrowserCacheLocation.LocalStorage,
        },
        system: {
          loggerOptions: {
            loggerCallback,
            logLevel: LogLevel.Info,
            piiLoggingEnabled: false
          }
        }
      },);
    }
    
    export function MSALInterceptorConfigFactory(): MsalInterceptorConfiguration {
      const protectedResourceMap = new Map<string, Array<string>>();
      protectedResourceMap.set('https://localhost:44491/weatherforecast', ['user.read']);
    
      return {
        interactionType: InteractionType.Redirect,
        protectedResourceMap
      };
    }
    
    export function MSALGuardConfigFactory(): MsalGuardConfiguration {
      return {
        interactionType: InteractionType.Redirect,
        authRequest: {
          scopes: ['user.read']
        },
        loginFailedRoute: '/login-failed'
      };
    }
    
    
    @NgModule({
      declarations: [
        AppComponent,
        NavMenuComponent,
        HomeComponent,
        CounterComponent,
        FetchDataComponent
      ],
      imports: [
        BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
        HttpClientModule,
        FormsModule,
        BrowserModule,
        HttpClientModule,
        JwtModule.forRoot({
          config: {
            tokenGetter: tokenGetter
          }
        }),
        MsalModule,
        RouterModule.forRoot([
          { path: '', component: HomeComponent, pathMatch: 'full' },
          { path: 'counter', component: CounterComponent },
          { path: 'fetch-data', component: FetchDataComponent },
        ])
      ],
      providers: [
        {
          provide: HTTP_INTERCEPTORS,
          useClass: MsalInterceptor,
          multi: true
        },
        {
          provide: MSAL_INSTANCE,
          useFactory: MSALInstanceFactory
        },
        {
          provide: MSAL_GUARD_CONFIG,
          useFactory: MSALGuardConfigFactory
        },
        {
          provide: MSAL_INTERCEPTOR_CONFIG,
          useFactory: MSALInterceptorConfigFactory
        },
        MsalService,
        MsalGuard,
        MsalBroadcastService
      ],
      bootstrap: [AppComponent, MsalRedirectComponent]
    })

app.component.ts


    export class AppComponent implements OnInit, OnDestroy {
      title = 'MSAL Sample';
      isIframe = false;
      loginDisplay = false;
      loggedIn = false;
    
      private readonly _destroying$ = new Subject<void>();
    
      constructor(
        @Inject(MSAL_GUARD_CONFIG) private msalGuardConfig: MsalGuardConfiguration,
        private authService: MsalService,
        private msalBroadcastService: MsalBroadcastService
      ) { }
    
      ngOnInit(): void {
        this.setLoginDisplay();
    
        this.authService.instance.enableAccountStorageEvents(); // Optional - This will enable ACCOUNT_ADDED and ACCOUNT_REMOVED events emitted when a user logs in or out of another tab or window
        this.msalBroadcastService.msalSubject$
          .pipe(
            filter((msg: EventMessage) => msg.eventType === EventType.ACCOUNT_ADDED || msg.eventType === EventType.ACCOUNT_REMOVED),
          )
          .subscribe((result: EventMessage) => {
            if (this.authService.instance.getAllAccounts().length === 0) {
              window.location.pathname = "/";
            } else {
              this.setLoginDisplay();
            }
          });
    
        this.msalBroadcastService.inProgress$
          .pipe(
            filter((status: InteractionStatus) => status === InteractionStatus.None),
            takeUntil(this._destroying$)
          )
          .subscribe(() => {
            this.setLoginDisplay();
            this.checkAndSetActiveAccount();
          })
    
        if (!this.loginDisplay) {
          this.loginRedirect();
        }
      }
    
      setLoginDisplay() {
        this.loginDisplay = this.authService.instance.getAllAccounts().length > 0;
      }
    
       checkAndSetActiveAccount() {
        /**
         * If no active account set but there are accounts signed in, sets first account to active account
         * To use active account set here, subscribe to inProgress$ first in your component
         * Note: Basic usage demonstrated. Your app may require more complicated account selection logic
         */
        let activeAccount = this.authService.instance.getActiveAccount();
    
        if (!activeAccount && this.authService.instance.getAllAccounts().length > 0) {
          let accounts = this.authService.instance.getAllAccounts();
          this.authService.instance.setActiveAccount(accounts[0]);
        }
      }
    
      loginRedirect() {
        if (this.msalGuardConfig.authRequest) {
          this.authService.loginRedirect({ ...this.msalGuardConfig.authRequest } as RedirectRequest);
        } else {
          this.authService.loginRedirect();
        }
      }
    
      loginPopup() {
        if (this.msalGuardConfig.authRequest) {
          this.authService.loginPopup({ ...this.msalGuardConfig.authRequest } as PopupRequest)
            .subscribe((response: AuthenticationResult) => {
              this.authService.instance.setActiveAccount(response.account);
            });
        } else {
          this.authService.loginPopup()
            .subscribe((response: AuthenticationResult) => {
              this.authService.instance.setActiveAccount(response.account);
            });
        }
      }
    
      logout(popup?: boolean) {
        if (popup) {
          this.authService.logoutPopup({
            mainWindowRedirectUri: "/"
          });
        } else {
          this.authService.logoutRedirect();
        }
      }
    
      ngOnDestroy(): void {
        this._destroying$.next(undefined);
        this._destroying$.complete();
      }
    }

Program.cs

    builder.Services.AddAuthentication(options =>
    {
        options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    }
        ).AddJwtBearer(options =>
    {
        options.Audience = builder.Configuration.GetValue<string>("AzureAd:Audience");
        options.Authority = builder.Configuration.GetValue<string>("AzureAd:Authority");
        options.RequireHttpsMetadata = builder.Configuration.GetValue<bool>("AzureAd:RequireHttpsMetadata");
    });

Variables used (just to show the format I provided:

    environment.clientID = "45xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxaf"
    environment.audience = "45xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxaf"
    environment.authority = "https://login.microsoftonline.com/4fxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxad/"
    environment.redirectUrl = "https://localhost:44491/"

When comparing the v1 and v2 bearer tokens I realized that the first line is different:

v1: "aud": "api://45xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxaf"
v2: "aud": "00000003-0000-0000-c000-000000000000" (this is actual data)

Update: This is caused by requesting the user.read scopes etc, these are only supported by the MS Graph API. Obviously MSAL recognizes this and sends you a graph token without even requesting one. Removing all scopes but the custom api://... one from the code solves that issue. Still, the verification fails.

Other things I tried:

In the API controller I added a simple [Authorize] decorator. I also tried to alter it with parameters like [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] as suggested here but that did not change anything. (Why would it as I can authenticate successfully with the token from V1.)

This answer somehow suggests that it is a problem with the Microsoft.AspNetCore.Authentication.JwtBearer v6.0.4 library that I am using. Tried installing the latest version of System.IdentityModel.Tokens.Jwt as suggested, without any success.

Some sources suggested to provide the correct scope. There is a scope defined in the app registration so I replaced the authRequest scopes in app.module.ts (protectedResourceMap.set and authRequest) with the following code, but this did not change anything. Also tried to only use "access_as_user", but that ended in an authentication loop.

    authRequest: {
      scopes: ['api://45xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxaf/access_as_user']
    },

There are some funny answers around like disabling some of the token validations which I tried for debugging purposes only, but not even that solved the problem.

Important before flagging as duplicate I am aware that similar questions have been asked. I literally went through all of those I could find or that were suggested before posting. Many are outdated (MSAL v1.0) or about the B2C auth flow which does not apply. There are other unanswered questions about similar scenarios, but here I tried to collect as much information as I possibly could find, and I will update the question with any new findings. And an answer, if I find one.



Solution 1:[1]

I did not find out what the root cause was. However, I ended up re-creating the whole project from scratch using the same code and the same library versions and then it worked without any issues. I compared both projects but could not find any obvious differences. I assume that maybe packet management got confused at some stage.

What I found out though is that the scopes are handled differently in v2 than they were in v1. In v1 it does not matter if I have "user.read" in the scopes as long as I have my user-defined api://.../... scope in the array. In v2 it only works if that one is the only scope I provide, otherwise it will default to MS Graph (See update above)

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Aileron79