API Management と Azure Functions で AAD 認証したものを Blazor WASM から呼ぶ

公開:2021/01/25
更新:2021/02/19
8 min読了の目安(約7600字TECH技術記事

↓の記事で作った API をせっかくなので Blazor WebAssembly から呼んでみようと思います。

API Management と Azure Functions で AAD 認証

Azure AD にアプリを登録

Blazor WebAssembly のクライアント用アプリを Azure AD に登録します。ポイントは以下の3点です。

  • プラットフォームでシングルページアプリケーションを選択
  • リダイレクト URL は https://xxxxxxxx/authentication/login-callback (ローカルでテストするときはデバッグ実行してみてポートを確認)
  • 暗黙的な許可およびハイブリッド フローで、ID トークン(暗黙的およびハイブリッド フローに使用) にチェックを入れる

作ったら、API のアクセス許可で前の記事で作った API を呼ぶアクセス許可を追加して管理者の同意を与えておきます。

Blazor 側の準備

Blazor WebAssembly アプリを認証無しで作ります。とりあえず勉強用に最初から手動で構成してみようと思います。普段は認証ありのテンプレートで作るといいと思います。

以下のパッケージを追加

  • Microsoft.Authentication.WebAssembly.Msal

wwwroot/index.html の blazor.webassembly.js を読み込んでいる script タグの前に以下の script タグを追加

    <script src="_content/Microsoft.Authentication.WebAssembly.Msal/AuthenticationService.js"></script>

Shared フォルダーに以下の2つのコンポーネントを追加

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

@inject NavigationManager Navigation
@inject SignOutSessionStateManager SignOutManager

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity.Name!
        <button class="nav-link btn btn-link" @onclick="BeginLogout">Log out</button>
    </Authorized>
    <NotAuthorized>
        <a href="authentication/login">Log in</a>
    </NotAuthorized>
</AuthorizeView>

@code{
    private async Task BeginLogout(MouseEventArgs args)
    {
        await SignOutManager.SetSignOutState();
        Navigation.NavigateTo("authentication/logout");
    }
}
RedirectToLogin.razor
@inject NavigationManager Navigation
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo($"authentication/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
    }
}

Pages フォルダーに Authentication.razor を追加

Authentication.razor
@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action" />

@code{
    [Parameter] public string Action { get; set; }
}

この Authentication.razor にサインイン後のコールバックが来たり、サインインとかサインアウトの処理が来る感じになります。

そして、App.razor に認証情報を子コンポーネントに伝搬する CascadingAuthenticationState と認証に対応した AuthorizeRouteView を使うように書き換えるのと、これらのコンポーネントが使えるように _Imports.razor に @using Microsoft.AspNetCore.Components.Authorization も追加します。

_Imports.razor
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using BlazorApp1
@using BlazorApp1.Shared
@using Microsoft.AspNetCore.Components.Authorization
App.razor
<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity.IsAuthenticated)
                    {
                        <RedirectToLogin />
                    }
                    else
                    {
                        <p>You are not authorized to access this resource.</p>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

続けて、wwwroot/appsettings.json を以下のようにします。 (無かったら作る)

appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AzureAd": {
    "Authority": "https://login.microsoftonline.com/Azure AD のテナント ID",
    "ClientId": "Azure AD に作ったアプリの Client ID",
    "ValidateAuthority": true
  }
}

仕上げに Program.cs に上記の appsettings.json の設定を読み込む処理を await builder.Build().RunAsync(); の手前に追加しましょう。

Program.cs
builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
    options.ProviderOptions.DefaultAccessTokenScopes
        .Add("https://graph.microsoft.com/User.Read");
});

設定や必要なコンポーネントなどはそろったので LoginDisplay コンポーネントを適当なところに置きます。認証ありのテンプレートから作ると MainLayout あたりに追加されますが、今回は単純に Index.razor に追加してみました。

Index.razor
@page "/"

<h1>Hello, world!</h1>

<div>
    <LoginDisplay />
</div>

実行すると、こんなページが表示されて

ログインを押すとポップアップが出てくるので Azure AD のアカウントでサインインすると、サインインしたアカウントの名前が表示されます。

ログインが出来るようになったので Blazor WASM から API を叩く前に API Management 側に CORS の設定を足しておきます。

All operations に対して cors の設定を追加しました。Inbound の validate-jwt の手前に以下のタグを入れます。

<cors allow-credentials="true">
    <allowed-origins>
        <origin>https://localhost:44315/</origin>
    </allowed-origins>
    <allowed-methods>
        <method>*</method>
    </allowed-methods>
    <allowed-headers>
        <header>*</header>
    </allowed-headers>
</cors>

では、API を呼んでみましょう。まずカスタムの AuthorizationMessageHandler を定義します。

Func1AuthorizationMessageHandler.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

namespace BlazorApp1.Authentication
{
    public class Func1AuthorizationMessageHandler : AuthorizationMessageHandler
    {
        public Func1AuthorizationMessageHandler(IAccessTokenProvider provider, NavigationManager navigation) : base(provider, navigation)
        {
            ConfigureHandler(
                authorizedUrls: new[] { "https://apim-okazukiauth1.azure-api.net/fun-okazukiauth1" },
                scopes: new[] { "https://fun-okazukiauth1.azurewebsites.net/user_impersonation" });
        }
    }
}

このハンドラーと HttpClient を紐づけることでトークンつきで API を叩けます。そのために HttpClientFactory を使うので Microsoft.Extensions.Http パッケージを追加します。
パッケージを追加したら HttpClient と先ほどのハンドラーを紐づけます。

Program.cs
builder.Services.AddScoped<Func1AuthorizationMessageHandler>();
builder.Services.AddHttpClient("Func1",
    client => client.BaseAddress = new("https://apim-okazukiauth1.azure-api.net"))
    .AddHttpMessageHandler<Func1AuthorizationMessageHandler>();

下準備が出来たので、Index.razor にボタンを追加して API を叩いてみましょう。

Index.razor
@page "/"
@inject IHttpClientFactory factory


<h1>Hello, world!</h1>

<div>
    <LoginDisplay />
</div>

<div>
    <button @onclick="InvokeFunc1">Func1</button>
    <div>
        @Func1Result
    </div>
</div>

@code {
    private string Func1Result { get; set; }
    private async void InvokeFunc1()
    {
        try
        {
            var client = factory.CreateClient("Func1");
            var res = await client.GetAsync("fun-okazukiauth1/Function1");
            Func1Result = $"{res.StatusCode}: {await res.Content.ReadAsStringAsync()}";
        }
        catch (Exception ex)
        {
            Func1Result = ex.ToString();
        }

        StateHasChanged();
    }
}

実行してサインインしてボタンを押すと以下のような感じになりました。ばっちりですね。