Angular app which invokes B2C-secured Function App

2019-08-04 13:00发布

问题:

Angular site hosted on an Azure storage account as a static website receives 500 when it's invoking an Azure B2C-protected Function App function. The function is receiving a 404.


Update

The original title for this question was "Angular app which invokes B2C-secured Functions App receives 401 Unauthorized response". The solution was, as @Alex AIT suggested (below), to replace the https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_name>.onmicrosoft.com/v2.0/.well-known/openid-configuration?p=<SignUpAndSignInPolicyName> in the Function App's Issuer URL with https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_name>.onmicrosoft.com/v2.0/. I.e., remove the trailing .well-known/openid-configuration?p=<SignUpAndSignInPolicyName> segments. In a subsequent chat session, Alex pointed out that the policy is part of the path such as https://<tenantname>.b2clogin.com/<tenantname>.onmicrosoft.c‌​om/<policyname>/v2.0 or https://<tenantname>.b2clogin.com/<tenantguid>/<policyname>/v2.0. However either of those paths for the Function App's Issuer URL reverts to the 401 response.

After resolving the 401 issue, the Angular SPA app now receives 500. However, the invoked API function is receiving a 404. The Function App's log stream indicates Failed to download OpenID configuration from 'https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_name>.onmicrosoft.com/v2.0/.well-known/openid-configuration': The remote server returned an error: (404) Not Found. So the policy is not getting attached.


My objective is to establish a secure, serverless Angular web application which is statically-hosted on an Azure Storage website (i.e., within the storage account's $web container). There are two projects: a public SPA Angular 7+ project and a protected API Function App project. Because Azure storage account static websites only allow public anonymous access to all files, the Angular app-hosting blob container's files (the website's files) are not secured. But the Angular app's invocations of the Azure Functions API calls are secured. The Function App API project is protected via Azure AD B2C authentication.

Toward this end, I have attempted to adapt the technique outlined in Single-Page Application built on MSAL.js with Azure AD B2C and Node.js Web API with Azure AD B2C. I was able to get these samples to run. And furthermore I was able to modify their settings to authenticate against my own Azure B2C tenant (rather than against Microsoft's B2C tenant) and run them locally. But I didn't attempt to deploy these sample projects to Azure and figure out the required tweaks for the settings. I skipped the deployment exercise because I'm not a Node.js developer.

But my subsequent adaptation of the code in those (Node.js) sample projects to my statically-hosted Angular SPA project and to my Azure Functions API project is yielding 401 Unauthorized whenever the API is invoked from the SPA. So I would like to understand how to resolve this issue.

The setup

Assumptions/Prerequisites

  1. An Azure B2C Tenant has been created
  2. Identity providers have been configured for the B2C Tenant
  3. A Sign-up and Sign-in user flow policy has been configured for the B2C Tenant
    • Make a note of its name. We'll refer to it's name below as <SignUpAndSignInPolicyName>
  4. An Azure Storage account has been created with its Static website feature enabled
  5. An Angular app has been created

    1. The @azure/msal-angular package has been installed
    2. In app-routing.module.ts,

      • the useHash option has been set: imports: [RouterModule.forRoot(routes, { useHash: true })],
        • Hash-routing is necessary to accommodate static hosting
      • A secure component has been created and a protected-route established

             const routes: Routes = [
                 { path: 'secure', component: SecureComponent, canActivate: [MsalGuard] },
                 { path: 'state', redirectTo: 'secure' }, // HACK/TODO
                 { path: 'error', redirectTo: 'secure' }, // HACK/TODO
                 { path: '', redirectTo: '', pathMatch: 'full' },
             ];
      
  6. An Azure Function App has been created

    • Make a note of the Function App's URL
  7. The following function has been created in the Function App for testing purposes. And it's been published to Azure:

    using System;
    using System.IO;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Azure.WebJobs;
    using Microsoft.Azure.WebJobs.Extensions.Http;
    using Microsoft.AspNetCore.Http;
    using Microsoft.Extensions.Logging;
    using Newtonsoft.Json;
    
    namespace SomeCompany.Functions
    {
        public static class HttpTriggerCSharp
        {
            [FunctionName("HttpTriggerCSharp")]
            public static async Task<IActionResult> Run(
                [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
                ILogger log)
            {
                log.LogInformation("C# HTTP trigger function processed a request.");
    
                string name = req.Query["name"];
    
                string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
                dynamic data = JsonConvert.DeserializeObject(requestBody);
                name = name ?? data?.name;
    
                return name != null
                    ? (ActionResult)new OkObjectResult($"Hello, {name}")
                    : new BadRequestObjectResult("Please pass a name on the query string or in the request body");
            }
        }
    }
    

B2C Tenant

API Application
  1. Create the API Application (i.e., name it API”)
  2. Make a note of its Application ID
    • The Application ID will be used later in the Function App's AAD Auth settings
  3. Set Include Web App / Web API to Yes
  4. Set the Allow implicit flow to Yes
  5. Set the Reply URL to https://<functionappname>.azurewebsites.net/.auth/login/aad/callback
    • Suffix the Function App's URL with /.auth/login/aad/callback
  6. Set the App ID URI segment to “API”
    • Yields: https://<b2c_tenant_name>.onmicrosoft.com/API
SPA Application
  1. Create the SPA Application (i.e., name it SPA”)
  2. Set Include Web App / Web API to Yes
  3. Set Allow implicit flow to Yes
  4. Set the Reply URL to http://localhost:4200
  5. In the API access tab, add the API application API
    • The only available scope “Access this app on behalf of the signed-in user (user_impersonation)” will be pre-selected

Primary (Non-B2C) Tenant

Function App
  1. In the Authentication / Authorization blade,
    • Set App Service Authentication to On
    • Set Action to take when request is not authenticated to Log in with Azure Active Directory
    • In the Authentication Providers section, configure the Azure Active Directory provider as follows:
      • Set Management Mode to Advanced
      • Set Client ID to the B2C API application's Application ID
      • Set the Issuer Url to https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_name>.onmicrosoft.com/v2.0/.well-known/openid-configuration?p=<SignUpAndSignInPolicyName>
  2. Save these Authentication / Authorization settings
Azure Application
  1. In the Angular application's app.module.ts NgModule import property, set:

    MsalModule.forRoot({
        clientID: '<B2C Tenant |> SPA Application |> Application ID>',
    
        // Note, for authority, the following doesn't work:
        //    B2C Tenant |> User flows (policies) |> <SignUpAndSignInPolicyName> |> Run user flow |> URL at top of the `Run user flow` blade
        //    I.e., `https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_name>.onmicrosoft.com/v2.0/.well-known/openid-configuration?p=<SignUpAndSignInPolicyName>`
        // Supposedly (according to various blog posts), that URL should be used as the `authority`. So, why doesn't it work?.
        // The following URL works. However, the B2C portal indicates that `login.microsoftonline.com` is to be deprecated soon
        authority: 'https://login.microsoftonline.com/tfp/<b2c_tenant_name>.onmicrosoft.com/<SignUpAndSignInPolicyName>',
    
        // B2C Tenant |> Applications |> API |> Published Scopes |> `user_impersonation` | FULL SCOPE VALUE
        consentScopes: ['https://<b2c_tenant_name>.onmicrosoft.com/API/user_impersonation'],
    })
    
  2. Create a component named Secure

    • ng g c Secure -s --skipTests

    • secure.component.ts

      import { Component } from '@angular/core';
      import { HttpClient, HttpHeaders } from '@angular/common/http';
      import { Subscription } from 'rxjs';
      import { MsalService } from '@azure/msal-angular';
      
      @Component({
          selector: 'app-secure',
          templateUrl: './secure.component.html',
      })
      export class SecureComponent  {
      
          constructor(private http: HttpClient, private msalService: MsalService) { }
      
          azureTestFunctionResponse: string;
      
          callApiWithAccessToken(accessToken: string) {
              const url = 'https://<function_app_name>.azurewebsites.net/api/HttpTriggerCSharp?name=HelloFromAzureFunction';
              const httpHeaders = new HttpHeaders({ Authorization: `Bearer ${accessToken}` });
              const subscription: Subscription = this.http.get(url, { headers: httpHeaders , responseType: 'text'}).subscribe(_ => {
                  this.azureTestFunctionResponse = _;
                  subscription.unsubscribe();
              });
          }
      
          invokeB2cSecuredAzureFunction() {
              // B2C Tenant |> `API` Application |> Published Scopes |> `user_impersonation` scope |> Full Scope Value
              const tokenRequest: string[] = ['https://<b2c_tenant_name>.onmicrosoft.com/API/user_impersonation'];
              this.msalService.acquireTokenSilent(tokenRequest)
                  .then(tokenResponse => {
                      this.callApiWithAccessToken(tokenResponse);
                  })
                  .catch(error1 => {
                      this.msalService.acquireTokenPopup(tokenRequest)
                          .then(tokenResponse => {
                              this.callApiWithAccessToken(tokenResponse);
                          })
                          .catch(error => {
                              console.log('Error acquiring the access token to call the Web api:\n' + error);
                          });
                  });
          }
      
      }
      
    • secure.component.html

      <h4>Secure Component</h4>
      
      <button (click)="invokeB2cSecuredAzureFunction()">Fetch data from B2C-secured Azure functions</button>
      <hr />
      <div>{{azureTestFunctionResponse}}</div>
      
  3. app.component.html

    <div style="text-align:center">
        <h4> {{ title }} </h4>
    </div>
    <mat-card style="float: left;">
        This site is a configuration demonstration of a secure, serverless Angular web application. The site is statically hosted on an
        <em>Azure Storage</em> website (<code>$web</code> container). The site's backend is secured
        by Azure <em>Business-to-Consumer</em>&nbsp;<span class="acronym">(B2C)</span> authentication. The site interacts with a secure
        <em>Azure Functions</em>&nbsp;<span class="acronym">API</span>.
    </mat-card>
    
    <p style="text-align: center;"><a routerLink="/" routerLinkActive="active">Home</a>&nbsp;&nbsp;<a routerLink="/secure" routerLinkActive="active">Secure</a></p>
    
    <p style="text-align: center;"><router-outlet></router-outlet></p>
    
  4. Serve the app locally: ng serve

  5. Click on the Secure link
    • Which navigates to the /secure route
    • Which prompts the user to authenticate
  6. Click on the Fetch data from B2C-secured Azure function button
  7. Server returns a 401 Not Authorized response
  8. If the SPA app's Reply URL is updated to the SPA static website URL and the SPA files are published, the 401 likewise gets returned when the API function is called.

So I'm not sure what's configured wrong. Any ideas?

回答1:

This is not the issuer of your tenant:

https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_name>.onmicrosoft.com/v2.0/.well-known/openid-configuration?p=<SignUpAndSignInPolicyName>

But if you open this URL in your browser, it will show your the issuer you search for.

It should be something like this:

https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_name>.onmicrosoft.com/v2.0
https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_guid>.onmicrosoft.com/v2.0
https://<b2c_tenant_name>.b2clogin.com/<b2c_tenant_name>.onmicrosoft.com/SignUpAndSignInPolicyName/v2.0
https://login.microsoftonline.com/<b2c_tenant_name>.onmicrosoft.com/v2.0

It might also be a good idea to choose either b2clogin.com and login.microsoftonline.com for both the Azure Function and the Angular app. I don't think you can mix them like this.

If you still have issues, you could try this as a scope instead of /user_impersonation:

https://<b2c_tenant_name>.onmicrosoft.com/API/.default

Or try adding https://<b2c_tenant_name>.onmicrosoft.com/API/user_impersonation to the allowed audiences in the Azure Function.



回答2:

Had the same issue as you described an looking though the responses posted I was able to solve it by changing the authority to:

https://<b2c_tenant_name>.b2clogin.com/tfp/<b2c_tenant_name>.onmicrosoft.com/<SignUpAndSignInPolicyName>

The standard one (https://<b2c_tenant_name>.microsoftonline.com/tfp/<b2c_tenant_name>.onmicrosoft.com/<SignUpAndSignInPolicyName>) was causing me to get a 401 when I tried to use the token on my Function app

Edit : Adding code sample

While my code is using react environment variables it's all just JS and should work the same in an angular app.

import * as Msal from 'msal';

/** @type {import('msal').Configuration} */
const msalConfig = {
    auth: {
        clientId: process.env.REACT_APP_CLIENT_ID,
        authority: 'https://<b2c_tenant_name>.b2clogin.com/tfp/<b2c_tenant_name>.onmicrosoft.com/<SignUpAndSignInPolicyName>',
        validateAuthority: false,
        navigateToLoginRequestUrl: false,
    },
    cache: {
        cacheLocation: 'localStorage',
        storeAuthStateInCookie: true,
    },
};
/** @type {import('msal').AuthenticationParameters} */
const reqParams = {
    scopes: [process.env.REACT_APP_SCOPE],
};
const clientApplication = new Msal.UserAgentApplication(msalConfig);
clientApplication.handleRedirectCallback((error, response) => {
    if (error) {
        if (error.message.indexOf('AADB2C90118') >= 0) {
            //User clicked forgot password
            clientApplication.authority = 'https://<b2c_tenant_name>.b2clogin.com/tfp/<b2c_tenant_name>.onmicrosoft.com/<ResetPasswordPolicyName>';
            clientApplication.loginRedirect(reqParams);
            return;
        }
        return console.error(error);
    }
});


回答3:

The solution for me was to change the security for the azure function in the standard settings to Anonymous (from Function) ... It seems it was expecting also a function code in addition to the bearer token... Took me 5+ hours to find out, as all my focus was on what could be wrong with the JWT access token, or the AADB2C configs etc ...

Oops, maybe I posted this in the wrong thread, I was getting 401 actually ...