Articoli

Azure B2C con Blazor WASM su Azure Static Web Apps

da |

Introduzione

Nel precedente articolo “Blazor Webassembly su Azure Static Web App“, ci siamo occupati di come distribuire la nostra applicazione Blazor WASM su Azure Static web Apps, mentre in questo descriveremo come evolvere il precedente progetto inserendo la parte di autenticazione\autorizzazione, configurando un tenant di Azure Active Directory B2C come “custom identity provider” nella nostra applicazione, grazie al file “staticwebapp.config.json” che permette la gestione di: custom provider, contenuti e percorsi.

L’applicazione Azure Static Web App utilizzerà OpenID Connect (OIDC) come autenticazione, un estensione del protocollo autorizzativo OAuth2, e permetterà all’utente finale di:

  1. Premere il pulsante di Login dalla Blazor WASM;
  2. L’applicazione direzionerà la richiesta su Azure AD B2C;
  3. L’utente procederà con la registrazione o login (se configurato dall’amministratore del tenant il processo di signUp/signIn potrebbe avvenire anche con accounts social);
  4. Dopo che la richiesta si completa con successo, Azure AD B2C invierà un token di identità (ID token) all’applicazione;
  5. L’applicazione validerà il token, con una chiave pubblica ricevuta dalla Microsoft identity platform, e creerà un cookie di sessione per identificare l’utente nelle successive richieste.

La fase di autenticazione si completerà, in modo a noi trasparente, con la restituzione dell’authorization_code, nella pagina che indicheremo nella sezione URI redirect durante la registrazione dell’applicazione nel tenant, che permetterà all’Azure Static Web App di richiedere un “access token” tramite il flusso di “Authorization Code Flow con chiave“, utile in fase di richiesta delle informazioni utente tramite path ” /.auth/me“.

Registrazione dell’applicazione nel Tenant

La registrazione e la gestione di utenti e applicativi, in ottica di un sistema CIAM (Custom Identity Access Managment), è governata dall’utilizzo di un Tenant, nel nostro caso AAD B2C (Azure Active Directory Business To Consumer), quindi assicuratevi di aver creato o linkato (in caso utilizzate uno esistente) un tenant (se volete approfondire l’argomento potete visionare l’articolo: Utilizzare Postman per testare API protette da Azure AD B2C leggendo la sezione Configurazione del tenant Azure AD B2C) e procedete con i seguenti step:

  1. Eseguite il login su Azure portal;
  2. Assicuratevi di utilizzare la direcotry del vostro Azure AD B2C tenant;
  3. Selezionate App registrations, and poi New registration;
  4. Nella casella del nome (Name), inserite il nome dell’applicazione (nel nostro caso Azure Static Web App);
  5. Selezionate come tipo di account (Supported account types), la terza opzione (Accounts in any identity provider or organizational directory) che permette l’utilizzo di “User flows” per gestire l’autenticazione degli utenti;
  6. Selezionate Web come tipologia di indirizzo di ritorno (Redirect URI), completando la box accanto con  https://<YOUR_SITE>/.auth/login/<NAME_IDENTITY_OPENDID_CUSTOM_PROVIDER>/callback. Sostituite <YOUR_SITE> con il nome dell’ Azure Static Web App creata in precedenza (nel nostro caso:https://zealous-pebble-0736ee303.1.azurestaticapps.net) e <NAME_IDENTITY_OPENDID_CUSTOM_PROVIDER> con il nome del “Custom Identity Provider” configurato nella sezione authdel file “staticwebapp.config.json” (nel nostro caso: b2c);
  7. Nella sezione permessi (Permissions), assicuratevi che la casella del consenso amministrativo per openid e offline access (Grant admin consent to openid and offline access permissions) sia selezionata.
  8. Completate premendo il pulsante di registrazione (Register);
  9. Selezionate la voce Overview dal menu, per registrare il (client) ID dell’applicazione per utilizzarlo successivamente quando si procederà con la configurazione della Static Web App.

Completata la registrazione, nella sezione Authentication, dell’app registrata, assicuratevi che i parametri precedentemente immessi siano corretti e completate la sezione di logout (Front-chanel logout URL) inserendo nella box il valore: https://<YOUR_SITE>/.auth/logout.
Sostituite <YOUR_SITE> con il nome dell’ Azure Static Web App creata in precedenza (nel nostro caso: https://zealous-pebble-0736ee303.1.azurestaticapps.net).

Step successivo è la creazione di una chiave utilizzata da Azure Static Web App per richiedere l’access token (tramite il flusso di “Authorization Code Flow con chiave“), dopo la fase di autenticazione. Procedete con i seguenti step:

  1. Dalla pagina di Azure AD B2C selezionate la sezione dove è presente la configurazione delle applicazioni (App registrations) e selezionate nel nostro caso Blazor Static web App;
  2. Dal menù di sinistra, selezionate dal gruppo di voci Manage la voce Certificates & secrets;
  3. Selezionate New client secret;
  4. Indicate una descrizione per la chiave nella casella Description  (nel nostro caso static web app configuration);
  5. Indicate un periodo di validità della chiave dalla box di elenco Expires e confermate le informazioni premendo il pulsante Add.
  6. Memorizzate il valore del campo Value per utilizzarlo in seguito. E’ bene ricordare che una volta completata la procedura e abbandonata la pagina, per questioni di sicurezza, non si potrà recuperare l’informazione contenuta nel campo Value.

Configurazione della risorsa Azure Static Web App

Assicuratevi, selezionando dal menu della risorsa la voce Hosting Plan, che il piano selezionato sia “Standard” altrimenti in fase di login, se il piano è lasciato come”Free“, l’applicazione Blazor Wasm indicherà l’impossibilità di risolvere il percorso per eseguire il redirect sulla pagina di signIn\signUp di AAD B2C (come da seconda immagine sotto riportata).

Prima di modificare il codice dell’applicazione Blazor WASM, i prossimi step descritti sono propedeutici a verificare che il flusso di autenticazione venga completato con successo.

Aprite il progetto in Visual Studio o Visual Studio Code, identificate il file di configurazione “staticwebapp.config.json” e aggiungete la sezione “auth” per impostare i parametri del nostro Custom Identity Provider (AAD B2C non rientra in quelli predefiniti) riportando in:

  • clientIdSettingName” e “clientSecretSettingNameil nome delle variabili da memorizzare nella sezione Configuration Application Setting della risorsa Azure Static Web App. Le due variabili devono contenere il ClientID e Client Secret memorizati in precedenza;
  • "wellKnownOpenIdConfiguration“, il percorso Uri “Azure AD B2C OpenID Connect metadata document” recuperabile nella sezione App registrations del tenant di Azure B2C selezionando la voce Endpoint. E’ bene ricordare di sostituire il tag <policy-name> del path con il flusso di SignUp/SignIn (nel nostro caso: https://blazorconfITA.b2clogin.com/blazorconfITA.onmicrosoft.com/b2c_1_susi/v2.0/.well-known/openid-configuration);
  • nameClaimType“, riportate il nome del Claim per reperire le informazioni della proprietà Name (inizialmente potete lasciarlo vuoto e inizializzarlo dopo la procedura di testing).

Il nome del Custom Identity Provider, nel nostro caso b2c, è utilizzato:

"auth": {
  "identityProviders": {
    "customOpenIdConnectProviders": {
      "b2c": {
        "registration": {
          "clientIdSettingName": "B2C_CLIENT_ID",
          "clientCredential": {
            "clientSecretSettingName": "B2C_CLIENT_SECRET"
          },
          "openIdConnectConfiguration": {
           "wellKnownOpenIdConfiguration":<Azure_AD_B2C_OpenID_Connect_metadata_document>
          }
        },
        "login": {
          "nameClaimType": "name",
          "scopes": [],
          "loginParameterNames": []
        }
      }
    }
  }
}
Configurazione ClientID e Secret

Eseguite un Commit e un successiva Push del repository locale, per aggiornare quello remoto su GitHub che attiverà tramite Action, la pubblicazione degli asset statici nella Static Web App. Attendete che la pubblicazione si completi con successo e procedete con:

  • Nella barra degli indirizzi del browser, inserite l’uri della vostra applicazione seguita da “/.auth/login/<NAME_IDENTITY_OPENDID_CUSTOM_PROVIDER>” (nel nostro caso https://zealous-pebble-0736ee303.1.azurestaticapps.net/.auth/login/b2c);
  • Completate la registrazione o la procedura di accesso;
  • Nella barra degli indirizzi del browser, inserite l’uri della vostra applicazione seguita da “/.auth/me” per recuperare le informazioni dell’utente:
{
  "clientPrincipal": {
    "identityProvider": "b2c",
    "userId": "xxxxx-xxxx-xxxx-xxx-xxxxxxx",
    "userDetails": "Damiano",
    "userRoles": ["authenticated", "anonymous"],
    "claims": [
      {
        "typ": "exp",
        "val": "1664206185"
      },
      {
        "typ": "nbf",
        "val": "1664202585"
      },
      {
        "typ": "ver",
        "val": "1.0"
      },
      {
        "typ": "iss",
        "val": "https://blazorconfita.b2clogin.com/<TENANT_ID>/v2.0/"
      },
      {
        "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
        "val": "xxxxx-xxxx-xxxx-xxx-xxxxxxx"
      },
      {
        "typ": "aud",
        "val": "f3acba5e-8c8e-4830-bde6-7daef5672076"
      },
      {
        "typ": "nonce",
        "val": "e0167028229a45918c1213139dc309f0_20220926143431"
      },
      {
        "typ": "iat",
        "val": "1664202585"
      },
      {
        "typ": "http://schemas.microsoft.com/ws/2008/06/identity/claims/authenticationinstant",
        "val": "1664202585"
      },
      {
        "typ": "city",
        "val": "Putignano"
      },
      {
        "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
        "val": "Andresini"
      },
      {
        "typ": "postalCode",
        "val": "70126"
      },
      {
        "typ": "extension_AnimalePreferito",
        "val": "3"
      },
      {
       "typ": "name",
       "val": "Damiano"
      },
      {
        "typ": "emails",
        "val": "d.andresini@cotrap.it"
      },
      {
        "typ": "tfp",
        "val": "B2C_1_susi"
      }
    ]
  }
}

Dal JSON restituito analizziamo i parametri ricevuti:

identityProviderIl nome del Custom Identity Provider configurato nella nostra applicazione Blazor Wasm (nel nostro caso b2c).
userIdIdentificatore univoco corrispondente all’ObjectID assegnato da AAD B2C in fase di registrazione.
userDetailsNome utente corrispondente al campo Name restituito da AAD B2C.
userRolesUna matrice dei ruoli (authenticated, anonymous) utili alla classe custom di AuthenticationStateProvider, che implementeremo nella nostra applicazione Blazor WASM per verificare lo stato dell’utente.
claimsL’elenco dei claim restituiti da AAD B2C configurati nel flusso utente oltre a quelli predefiniti.

Modifica dell’applicativo Blazor Wasm

Aggiungete all’interno del file _Imports.razor del progetto Client, i riferimenti agli oggetti che permettono di gestire le operazioni di Autenticazione:

@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

(Se Visual Studio vi segnala un errore, aggiungete i pacchetti selezionando da menu: “Tools -> NuGet Package Manage -> NuGet Package Manage for Solution“).

File di configurazione

Per disattivare gli Identity Provider nativi dall’Azure Static Web App aggiungete, nella sezione “routes” del file “staticwebapp.config.js”, il codice seguente (in grassetto è identificata l’unica rotta valida):

    {
      "route": "/.auth/login/b2c",
      "allowedRoles": [ "anonymous" ]
    },
    {
      "route": "/.auth/login/aad",
      "allowedRoles": [ "anonymous", "authenticated" ],
      "statusCode": 404
    },
    {
      "route": "/.auth/login/apple",
      "allowedRoles": [ "anonymous", "authenticated" ],
      "statusCode": 404
    },
    {
      "route": "/.auth/login/facebook",
      "allowedRoles": [ "anonymous", "authenticated" ],
      "statusCode": 404
    },
    {
      "route": "/.auth/login/github",
      "allowedRoles": [ "anonymous", "authenticated" ],
      "statusCode": 404
    },
    {
      "route": "/.auth/login/google",
      "allowedRoles": [ "anonyous", "authenticated" ],
      "statusCode": 404
    },
    {
      "route": "/.auth/login/twitter",
      "allowedRoles": [ "anonymous", "authenticated" ],
      "statusCode": 404
    }

Per semplicità, sempre nella sezione “routes” del file “staticwebapp.config.js”, aggiungete delle rotte per mascherare quelle di accesso\disconnesisone e indicate le regole di accesso al profilo utente (sezione in grassetto):

   {
      "route": "/login*",
      "allowedRoles": [ "anonymous" ],
      "rewrite": "/.auth/login/b2c"
    },
    {
      "route": "/logout*",
      "allowedRoles": [ "authenticated" ],
      "rewrite": "/.auth/logout"
    },
    {
      "route": "/.auth/me",
      "allowedRoles": ["authenticated","anonymous"]
    },

Aggiungete nella root del file “staticwebapp.config.js”, una regola che richiama la pagina di autenticazione del flusso signIn/signUp di Azure B2C quando si riceve il messaggio di errore: “401 Utente non Autorizzato“:

"responseOverrides": {
    "401": {
      "statusCode": 302,
      "redirect": "/.auth/login/b2c"
    }
  },

Authentication State Provider

Per reperire lo stato di autenticazione, create un servizio DI (Dependency Injection), nello specifico una classe personalizzata che fornisce lo stato di autenticazione dell’utente implementando la classe astratta “AuthenticationStateProvider“.

Primo step, create una classe per deserializzare i dati JSON della chiamata “/.auth/me“:

namespace BlazorApp.Client.StaticWebAppAuthExtension.Model
{
    public class AuthenticationData
    {
        public ClientPrincipal? ClientPrincipal { get; set; }
    }

    public class ClientPrincipal
    {
        public string? IdentityProvider { get; set; }
        public string? UserId { get; set; }
        public string? UserDetails { get; set; }
        public IEnumerable<string>? UserRoles { get; set; }
        public IEnumerable<CustomClaim>? Claims { get; set; }
    }

    public class CustomClaim
    {
        public string Typ { get; set; } = "";
        public string Val { get; set; } = "";
    }
}

Secondo Step, implementate il GetAuthenticationStateAsync della classe astratta AuthenticationStateProvider. Il metodo:

  • Chiama l’URI “/.auth/me” (è specificato anche una chiave di config per impostare un percorso locale per eseguire dei test interni) e salva la risposta nella variabile “principal“;
  • Verifica che l’oggetto non sia nullo e contenga almeno un elenco di “UserRoles” (scartando la voce anonymous);
  • Crea un oggetto di tipo ClaimsIdentity e aggiunge le informazioni passate per poi restituirle.
using BlazorApp.Client.StaticWebAppAuthExtension.Model;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using System.Net.Http.Json;
using System.Security.Claims;

namespace BlazorApp.Client.StaticWebAppAuthExtension
{
    public class StaticWebAppsAuthenticationStateProvider : AuthenticationStateProvider
    {
        private readonly IConfiguration config;
        private readonly HttpClient http;


        public StaticWebAppsAuthenticationStateProvider(IConfiguration config, IWebAssemblyHostEnvironment environment)
        {
            this.config = config;
            this.http = new HttpClient { BaseAddress = new Uri(environment.BaseAddress) };
        }

        public async override Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            try
            {
                var authDataUrl = config.GetValue<string>("StaticWebAppsAuthentication:AuthenticationDataUrl", "/.auth/me");
                var data = await http.GetFromJsonAsync<AuthenticationData>(authDataUrl);

                var principal = data!.ClientPrincipal;
                if (principal is not null )
                {
                    if (principal.UserRoles is not null)
                        principal.UserRoles = principal.UserRoles.Except(new string[] { "anonymous" }, StringComparer.CurrentCultureIgnoreCase);

                    var identity = new ClaimsIdentity(principal.IdentityProvider);
                    if (principal.UserDetails is not null) identity.AddClaim(new Claim(ClaimTypes.Name, principal.UserDetails));
                    if (principal.UserRoles is not null) identity.AddClaims(principal.UserRoles.Select(r => new Claim(ClaimTypes.Role, r)));
                    if (principal.Claims is not null)
                    {
                        foreach (var claim in principal.Claims.Where(c => c.Typ != "" && c.Val != ""))
                            identity.AddClaim(new Claim(claim.Typ, claim.Val));
                    }

                    return new AuthenticationState(new ClaimsPrincipal(identity));

                }

                return new AuthenticationState(new ClaimsPrincipal());    
            }
            catch
            {
                return new AuthenticationState(new ClaimsPrincipal());
            }
        }
    }
}


Terzo Step, create un Extension Method che aggiunge il servizio nella ServiceCollection del builder del WebAssemblyHost:

using Microsoft.AspNetCore.Components.Authorization;

namespace BlazorApp.Client.StaticWebAppAuthExtension
{

    public static class StaticWebAppsAuthenticationExtensions
    {
        public static IServiceCollection AddStaticWebAppsAuthentication(this IServiceCollection services)
        {
            return services
               .AddAuthorizationCore()
               .AddScoped<AuthenticationStateProvider, StaticWebAppsAuthenticationStateProvider>();
        }
    }
}

Ultimo Step, aggiungete il servizio nel file Program.cs:

using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using BlazorApp.Client;
using BlazorApp.Client.StaticWebAppAuthExtension;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services
    .AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.Configuration["API_Prefix"] ?? builder.HostEnvironment.BaseAddress) })
    .AddStaticWebAppsAuthentication();

await builder.Build().RunAsync();

LoginDisplay.razor

Aggiungete nella Cartella “Shared” il file “LoginDisplay.razor” ed implementate, in base allo stato dell’utente, le informazioni da visualizzare. Nel nostro caso se: l’utente è autenticato verrà visualizzato il campo Nome e il link di disconnessione, in caso contrario il link di accesso della pagina di login.

@inject NavigationManager Navigation

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity?.Name!
        <a href="/logout">Log out</a>
    </Authorized>
    <NotAuthorized>
        <a href="/login">Log in</a>
    </NotAuthorized>
</AuthorizeView>

MainLayout.razor

Inserite il componente <LoginDisplay /> nel file MainLayout.razor

@inherits LayoutComponentBase
<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
           <LoginDisplay />
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            @Body
        </article>
    </main>
</div>

RedirectToLogin.razor

Aggiungete nella Cartella “Shared” il file “RedirectToLogin.razor” per eseguire il redirect nel caso l’utente tenti di aprire una pagina soggetta ad Autorizzazione.

@inject NavigationManager Navigation

@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo($"/login?post_login_redirect_uri={Uri.EscapeDataString(Navigation.Uri)}",true);
    }
}

(durante la chiamata del metodo è stato forzato il caricamento dell’uri perchè non eseguiva il redirect alla pagina di Login, se un utente anonimo selezionava da menu la pagina protetta “FetchData“).

App.razor

Aggiungete in testa al file “App.razor” il tag <CascadingAuthenticationState>, che propaga lo stato di autenticazione dell’utente in tutte le pagine della Blazor WASM e sostituite il tag <RouteView> con <AuthorizeRouteView>:

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
          <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (context.User.Identity?.IsAuthenticated != true)
                    {
                        <RedirectToLogin />
                    }
                    else
                    {
                        <p role="alert">You are not authorized to access this resource.</p>
                    }
                </NotAuthorized>
          </AuthorizeRouteView>
          <FocusOnNavigate RouteData="@routeData" Selector="h1" />
        </Found>
        <NotFound>
            <PageTitle>Not found</PageTitle>
            <LayoutView Layout="@typeof(MainLayout)">
                <p role="alert">Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

FetchData.razor

Modificate la pagina “FetchData.razor”, aggiungendo l’attributo [Authorize] e solo a scopo dimostrativo iniettate il servizio di “AuthenticationStateProvider” per visualizzare i Claims dell’utente. Inserite la chiamata API all’interno di un’istruzione try\catch così da gestire il redirect alla pagina di login quando si solleva un eccezione di tipo: “AccessTokenNotAvailableException”:

@page "/fetchdata"
@using BlazorApp.Shared 
@using System.Security.Claims
@inject NavigationManager navigationManager; 
@inject AuthenticationStateProvider GetAuthenticationStateAsync
@inject HttpClient Http
@attribute [Authorize]

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (forecasts == null)
{
    <p>Loading...</p>
}
else
{
    <ul>
        @if (userClaims is not null){
            @foreach (var claim in userClaims)
            {
                <li>
                    @claim.Type "->" @claim.Value
                </li>
            }
        }
        
    </ul>
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private WeatherForecast[] forecasts = new WeatherForecast[]{};
    private IEnumerable<Claim>? userClaims;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            var authstate = await GetAuthenticationStateAsync.GetAuthenticationStateAsync();
            userClaims = authstate.User.Claims

            forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("/api/WeatherForecast") ?? new WeatherForecast[]{};
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }
}

Prima di passare all’analisi e cofigurazione della chiamata Api eseguite una Commit delle modifiche con una successiva Push.

Azure Function

L’API esposta dall’Azure Function, nella risorsa di Azure Static Web App, accede alle stesse informazioni dell’utente dell’applicazione Blazor Wasm, ma non esegue alcun controllo di autenticazione in quanto le regole di accesso sono definite nel file di configurazione “staticwebapp.config.json" nella sezione “routes”:

"routes": [
  {
    "route": "/api/*",
    "allowedRoles": [ "authenticated" ]
  }
],

(L’informazione sopra descritta deve essere aggiunta alle regole di routing precedentemente indicate)


Le informazioni dell’utente, a livello di Azure Function, sono reperibili nell’intestazione (header) “x-ms-client-principal" e deserializzando il contenuto, nel nostro caso, otteniamo un JSON con i seguenti valori:

{
   "identityProvider":"b2c",
   "userId":"xxxxx-xxxx-xxxx-xxx-xxxxxxx",
   "userDetails":"Damiano",
   "userRoles":["authenticated","anonymous"]
}

Come potete notare, le informazioni non contengono gli stessi claims restituiti da Azure B2C e intercettatti a livello cliente dalla classe custom: “AuthenticationStateProvider“.
Per ovviare al problema, nel caso si rendano necessarie le informazioni dell’utente, potete sfruttare le Microsoft Graph API utilizzando l’informazione di “userId” che corrisponde all’Object(ID) assegnato dal tenant in fase di registrazione.

Primo step, spostate la classe “ClientPrincipal“, creata precedentemente, nel progetto “Shared” così da utilizzarla nelle Azure Function per deserializzare i dati JSON dell’header “x-ms-client-principal

using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace BlazorApp.Shared>
{
    public class ClientPrincipal
    {
        public string? IdentityProvider { get; set; }
        public string? UserId { get; set; }
        public string? UserDetails { get; set; }
        [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
        public IEnumerable<string>? UserRoles { get; set; }
        [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
        public IEnumerable<CustomClaim>? Claims { get; set; }
    }

    public class CustomClaim
    {
        public string Typ { get; set; } = "";
        public string Val { get; set; } = "";
    }
}

(Assicuratevi che il progetto Shared abbia “<TargetFramework> netstandard2.1<TargetFramework>” e abbia abilitato la gestione dei valori Null “<Nullable>enable</Nullable>“).

Second Step, per utilizzare il pacchetto di Microsoft Graph API aggiungete al progetto “Api” la classe di “Startup” (file Startup.cs) che permette di registrare tutte le dipendenze dell’Azure Function, nel nostro caso metteremo a disposizione, ad ogni request, un oggetto GraphServiceClient:

using Azure.Identity;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using System;

[assembly: FunctionsStartup(typeof(BlazorApp.Api.Startup))]
namespace BlazorApp.Api
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            {
                builder.Services.AddScoped(implementationFactory =>
                {
                    TokenCredentialOptions options = new TokenCredentialOptions
                    {
                        AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
                    };

                    //
                    var clientSecretCredential = new ClientSecretCredential(
                    Environment.GetEnvironmentVariable("B2C_TENANTID"),
                    Environment.GetEnvironmentVariable("B2C_CLIENT_ID"),
                    Environment.GetEnvironmentVariable("B2C_CLIENT_SECRET"),
                    options);

                    return new Microsoft.Graph.GraphServiceClient(clientSecretCredential, new[] {"https://graph.microsoft.com/.default" });
                });
            }
        }
    }
}

Le Azure function utilizzano lo stesso “Application Settings” dell’Azure Static Web App, quindi basterà aggiungere la variabile “B2C_TENANTID“, che come indicato dal nome, conterrà l’ID del tenant recuperabile nella sezione “Overview” della risorsa AAD B2C. E’ bene ricordare di controllare se la nostra application, nella sezione “Api Permissions” abbia i diritti di “User.Read.All” per accedere alle Microsoft Graph API.

Ultimo Step, modificate la classe “WeatherForecastFunction“, presente nel file “WeatherForecastFunction.cs” con :

  • L’Inserimento di un costruttore per recuperare la “GraphServiceClient” tramite DI (Denpendency Injection);
  • L’Inserimento nel metodo “Run” di una variabile di tipo “ClientPrincipal” per memorizzare le informazioni dell’utente ricevute dalla proprietà “x-ms-client-principal dell’header della request;
  • Con la verifica nella lista “UserRoles” dell’oggetto “ClientPrincipal” sia presente un valore differente da “anonymous” e in caso positivo, chiamate le procedure di Microsoft Graph Api per recuperare i restanti Claims dell’utente, per poi restituire le informazioni se sono passati tutti i controlli di sicurezza (nell’esempio abbiamo riportato la chiamata ma è un ottimo punto di partenza).
using System;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using BlazorApp.Shared;
using System.Text;
using System.Text.Json;
using Microsoft.Graph;
using System.Threading.Tasks;

namespace BlazorApp.Api
{
    public class WeatherForecastFunction
    {
        private readonly GraphServiceClient graphClient;
        public WeatherForecastFunction(GraphServiceClient GraphClient)
        {
            graphClient= GraphClient;
        }

        private static string GetSummary(int temp)
        {
            var summary = "Mild";

            if (temp >= 32)
            {
                summary = "Hot";
            }
            else if (temp <= 16 && temp > 0)
            {
                summary = "Cold";
            }
            else if (temp <= 0)
            {
                summary = "Freezing!";
            }

            return summary;
        }

        [FunctionName("WeatherForecast")]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req,
            ILogger log)
        {
            var principal = new ClientPrincipal();

            if (req.Headers.TryGetValue("x-ms-client-principal", out var header))
            {

                var data = header[0];
                var decoded = Convert.FromBase64String(data);
                var json = Encoding.UTF8.GetString(decoded);
                principal = JsonSerializer.Deserialize<ClientPrincipal>(json);
            }

            principal.UserRoles = principal.UserRoles?.Except(new string[] { "anonymous" }, StringComparer.CurrentCultureIgnoreCase);
            
            if (!principal.UserRoles?.Any() ?? true)
            {

                var user = await graphClient.Users[$"{principal.UserId}"]
                           .Request()
                           .GetAsync();


                var randomNumber = new Random();
                var temp = 0;

                var result = Enumerable.Range(1, 5).Select(index => new WeatherForecast
                {
                    Date = DateTime.Now.AddDays(index),
                    TemperatureC = temp = randomNumber.Next(-20, 55),
                    Summary = GetSummary(temp)
                }).ToArray();

                return new OkObjectResult(result);
            }

            return new UnauthorizedResult();    
        }
    }
}

Eseguite un Commit delle modifiche con una successiva Push e attendete il completamento dell’operazione di build della GitHub Action. Recuperate il link della vostra Blazor Wasm e buon divertimento 🙂

Conclusioni

Attualmente è aperta un Issue sul progetto Static Web App, per ricevere l’access_token come informazione nella chiamata di “/.auth/me“: “se questa venisse risolta, permetterebbe l’apertura di scenari ben più complessi di quelli attuali”.

Ricordate:

La crescita personale è come un investimento; Non è una questione di opportunità, ma di tempo.

John C. Maxwell

Link Utili

Scritto da: