SAKURUG TECHBLOG

Blazor WebAssembly HostedにおけるEntra ID認証

timestampauthor-name
Tomomitsu

始めに

Blazor WebAssembly ASP.NET Core HostedのモデルにおいてEntra IDでの認証をかけるのはどうしたらよいか考えてみる。
Blazor WebAssembly ASP.NET Core Hostedのモデルはクライアント側はASP.NET Core実装のWebAssemblyで実装されており、そこでBlazorのフレームワークが動作している。一方、サーバー側ではASP.NET Core WebAPIで実装されており、双方ともC#で記述可能なことから開発者が複数の言語を覚える必要がなく高効率な開発が可能になっている。
また、サーバーとクライアントはREST APIでやりとりするが、通常JSONの「型」を合わせるという作業が発生する。
この、Blazor WebAssembly ASP.NET Core HostedのモデルではSharedプロジェクトにこのREST APIでやりとりするオブジェクトの「型」を定義しておき、clientプロジェクト、Serverプロジェクト双方から参照することによりこの問題を解決している。

Entra IDにサーバーAPIアプリを登録する

Azure Active Directory(AAD)はまもなくMicrosoft Entra IDという名称になります。
2023年8月11日執筆時点ではまだAzure Portal上ではAzureActive Directoryの表記が残っていますが、まもなくMicrosoft Entra IDになりますので読み替えてください。

Azure portalでAzure Active Directory(Microsoft Entra ID)に移動します。 左側サイドバーで、「アプリの登録」を選択します。上のメニューから「新規登録」ボタンを選択します。

アプリの名前を指定します。(この名前はあとで使います。ここでは「BlazorServerEID」とします。)

サポートされているアカウントの種類を選択します。ここでは「この組織内のディレクトリ内のアカウントのみ(シングルテナント)」を選択します。

リダイレクトURIは入力しません。


登録します。

APIのアクセス許可>Microsoft Graph> User.Readアクセス許可を削除します。

APIの公開で
api://{SERVER API APP CLIENT ID} の形式でアプリ ID URI を確定または追加します。



Scopeの追加を選択します。


スコープ名、管理者の同意の表示名、管理者の同意の説明、をそれぞれ入力します。
ここでは、説明のために、スコープ名を「API.Access」としておきます。

次の情報を記録しておきます。

アプリID URI GUID 例)api://41451fa7-82d9-4673-8fa5-69eff5a761fd
スコープ名 例)API.Access
テナントID 例)86c78e2-8bb4-4c41-aefd-918e0565a45e
テナントドメイン 例)contoso.onmicrosoft.com

Entra IDにクライアントアプリを登録する


Azure portal で 「Azure Active Directory」(Microsoft Entra ID) に移動します。 サイドバーで 「アプリの登録」 を選択します。 「新規登録」ボタンを選択します。

アプリの 「名前」を指定します。(ここでは、「BlazorClientEID」とします。)

「サポートされているアカウントの種類」 を選択します。 このエクスペリエンスでは、 「この組織のディレクトリ内のアカウントのみ (シングル テナント)」 を選択します。

「リダイレクト URI」 ドロップダウン リストを 「シングルページ アプリケーション (SPA)」 に設定し、次のリダイレクト URI を指定します: https://localhost/authentication/login-callback




「認証」>「プラットフォーム構成」>「シングルページ アプリケーション」 で次のようにします。

リダイレクト URI が https://localhost/authentication/login-callback であることを確認します。
[暗黙の付与] セクションで、[アクセス トークン] と [ID トークン] のチェックボックスが選択されていないことを確認します。



[API のアクセス許可] で:
アプリに Microsoft Graph>User.Read アクセス許可があることを確認します。



[アクセス許可の追加] を選択し、 [自分の API] を選択します。
APIの一覧を開きます。
APIへのアクセスを有効にします(API.Access)
[アクセス許可の追加] を押下します.

Azure portal の Server アプリの構成で、[API の公開] を選択します。 [承認済みのクライアント アプリケーション] で、[クライアント アプリケーションの追加] ボタンを選択します。 Client アプリのアプリケーション (クライアント) ID を追加します




以下の情報を記録しておきます。
Clientアプリのアプリケーション(クライアントID) 例)4369008b-21fa-427c-abaa-9b53bf58e538

既存のBlazor WASM HostedアプリにOIDC認証を追加する


注意

OIDCアプリの識別子の形成を妨げるダッシュ(-)をプロジェクト名に使用しないでください。
Blazor WebAssemblyプロジェクトテンプレート内のロジックではOIDCアプリの識別子にプロジェクト名を使用します。

Nuget

Clientプロジェクトに以下のパッケージをNugetします。

Microsoft.Authentication.WebAssembly.Msal

推移的に以下のパッケージがインストールされます。
Microsoft.AspNetCore.Components.WebAssembly.Authentication

Serverプロジェクトに以下のパッケージをNugetします。
Microsoft.Identity.Web

推移的に以下のパッケージがインストールされます。
Microsoft.AspNetCore.Authentication.OpenIdConnect
Microsoft.AspNetCore.Authentication.JwtBearer

ServerプロジェクトProgram.cs

Microsoft.AspNetCore.Authentication.JwtBearerの名前空間を追加します。

using Microsoft.AspNetCore.Authentication.JwtBearer;

JwtBearerOptions の TokenValidationParameters.NameClaimType を構成します。

AddAuthentication メソッドにより、アプリ内での認証サービスが設定され、JWT ベアラー ハンドラーが既定の認証方法として構成されます。 AddMicrosoftIdentityWebApi メソッドによって、Microsoft Identity Platform v2.0 を使用して Web API を保護するようにサービスを構成できます。 このメソッドでは、認証オプションを初期化するために必要な設定で、アプリの構成の AzureAd セクションが想定されます。

UseAuthentication と UseAuthorization により、次のようになります。

アプリにより、受信要求のトークンの解析と検証が試みられます。
適切な資格情報なしで保護されたリソースへのアクセスを試みた要求は失敗します。

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));

builder.Services.Configure<JwtBearerOptions>(options =>
{
    options.TokenValidationParameters.NameClaimType = "name";
});

....

var app = builder.Build();

....

app.UseAuthentication();
app.UseAuthorization();

....

app.Run()


Serverプロジェクトの構成

サーバー側プロジェクトのappsettings.jsonまたはローカルシークレット(推奨)を編集し次の記述を追加します。

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "{テナントドメイン}",
    "TenantId": "{テナントID}",
    "ClientId": "{サーバーのクライアントID}",
    "CallbackPath": "/signin-oidc",
    "Scopes": "{スコープ名}"
  }
}


コントローラー

例として、テンプレートに存在するWeatherForecastコントローラー(Controllers/WeatherForecastController.cs)について取り上げます。
[Authorize] 属性が適用されている保護された API をコントローラーに公開します。 次のことを理解しておくことが重要です。

  • この API コントローラーの [Authorize] 属性は、この API を不正アクセスから保護する唯一のものです。
  • Blazor WebAssembly アプリで使用される [Authorize] 属性は、アプリが正しく動作するにはユーザーを承認する必要がある、というアプリへのヒントとしてのみ機能します。


[Authorize]
[ApiController]
[Route("[controller]")]
[RequiredScope(RequiredScopesConfigurationKey = "AzureAd:Scopes")]
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    public IEnumerable<WeatherForecast> Get()
    {
        ...
    }
}


Clientプロジェクトの構成


ClientプロジェクトのWWWRoot/appsettings.jsonを編集して次の記述を追加します。
このファイルがない場合は、作成します。

{
  "AzureAd": {
    "Authority": "https://login.microsoftonline.com/{テナントID}",
    "ClientId": "{クライアントアプリのクライアントID}",
    "ValidateAuthority": true
  }
}


ClientプロジェクトProgram.cs

Server アプリへの要求を行うときのアクセス トークンが含まれる HttpClient インスタンスのサポートが追加されます。
プロジェクト名は、ソリューション作成時のプロジェクト名です。

ユーザーの認証に対するサポートは、Microsoft.Authentication.WebAssembly.Msal パッケージによって提供される AddMsalAuthentication 拡張メソッドを使用して、サービス コンテナーに登録されます。 このメソッドでは、アプリが IdentityID プロバイダー (IP) とやり取りするために必要なサービスが設定されます。
既定のアクセス トークン スコープでは、次のようなアクセス トークン スコープの一覧が表されます。

サインイン要求に既定で含まれます。
認証直後にアクセス トークンをプロビジョニングするために使用されます。

builder.Services.AddHttpClient("{プロジェクト名}.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
    .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();


// Supply HttpClient instances that include access tokens when making requests to the server project
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("{プロジェクト名}.ServerAPI"));


builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
    options.ProviderOptions.DefaultAccessTokenScopes.Add("{スコープURI}");
});


インポートファイル

_Imports.razorにMicrosoft.AspNetCore.Components.Authorization を追加します。

Indexページ

wwwroot/index.htmlページにJavaScript で AuthenticationService を定義するスクリプトが含まれています。 AuthenticationService によって、OIDC プロトコルの下位レベルの詳細が処理されます。 アプリは、認証操作を実行するために、スクリプトで定義されているメソッドを内部的に呼び出します。
これを追加します。

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


Authentication.razor

Authentication コンポーネント (Pages/Authentication.razor) によって生成されるページによって、さまざまな認証ステージを処理するために必要なルートが定義されます。

以下のようなコードでAuthentication.razorを作成します。

@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication


<RemoteAuthenticatorView Action="@Action" />


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


RemoteAuthenticatorView コンポーネント:

  • Microsoft.AspNetCore.Components.WebAssembly.Authentication パッケージによって提供されます。
  • 認証の各段階における適切なアクションの実行を管理します。


LoginDisplayコンポーネント

LoginDisplay コンポーネント
"このセクションは、ソリューションの Client アプリに関連しています。 "

LoginDisplay コンポーネント (Shared/LoginDisplay.razor) は MainLayout コンポーネント (Shared/MainLayout.razor) でレンダリングされます。このコンポーネントによって次の動作が管理されます。

  • 認証されたユーザーの場合:
    • 現在のユーザー名が表示されます。
    • ASP.NET Core Identity のユーザー プロファイル ページへのリンクが提供されます。
    • アプリからログアウトするためのボタンが用意されます。
  • 匿名ユーザーの場合:
    • 登録するオプションが提供されます。
    • ログインするオプションが提供されます。


次のようなコードで作成します。

@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コンポーネント

RedirectToLogin コンポーネント (Shared/RedirectToLogin.razor) は:

  • 承認されていないユーザーのログイン ページへのリダイレクトを管理します。
  • ユーザーがアクセスしようとしている現在の URL は、次を使用して認証が成功した場合にそのページに戻ることができるように保持されます。
    • ASP.NET Core 7.0 以降のナビゲーション履歴の状態。
    • ASP.NET Core 6.0 以前のクエリ文字列。


次のようなコードで作成します。

@inject NavigationManager Navigation

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


Appコンポーネント

App コンポーネント (App.razor) は、Blazor Server アプリにある App コンポーネントに似ています。

  • CascadingAuthenticationState コンポーネントによって、アプリの残りの部分に AuthenticationState を公開する動作が管理されます。
  • AuthorizeRouteView コンポーネントによって、現在のユーザーには所与のページへのアクセスが許可されます。それ以外では、RedirectToLogin コンポーネントがレンダリングされます。
  • RedirectToLogin コンポーネントによって、承認されていないユーザーのログイン ページへのリダイレクトが管理されます。


以下のように書き換えます。(.NET 6の場合)

<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コンポーネント

一般に、Serverアプリで保護されたWebAPIを呼び出す方法を示します。
@attribute [Authorize] ディレクティブは、このコンポーネントにアクセスするためにユーザーを承認する必要があるということをBlazor WebAssemblyの承認システムに示します。Clientアプリにこの属性が存在していても適切な資格情報がないにもかかわらずサーバ上のAPIが呼び出されることを防ぐことはできません。Serverアプリ側はコントローラーで[Authorize]を使用する必要があります。

以下に、コード例を示します。

@page "/fetchdata"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using {APP NAMESPACE}.Shared
@attribute [Authorize]
@inject HttpClient Http

...


@code {
    private WeatherForecast[] forecasts;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
        }
        catch (AccessTokenNotAvailableException exception)
        {
            exception.Redirect();
        }
    }
}


ユーザーの操作なしでトークンをプロビジョニングできなかったために要求が失敗した場合、トークン結果にはリダイレクト URL が含まれます。 この URL に移動すると、認証が成功した後、ユーザーがログイン ページに移動してから、現在のページに戻ります。

実際に動かしてみた。



認証が必要なページに遷移すると、アカウントサインインのページがポップアップしてアカウントにサインインするように求められます。



ログインに成功すると、左上のLoginDisplayコンポーネント部分にログインしたことを示すEntra IDに登録されている表示名が表示されました。



これは、Microsoft Entra IDに登録されている表示名と一致していることが分かります。

以上で、Blazor WebAssembly ASP.NET Core HostedのモデルでMicrosoft Entra ID認証をかけました。

記事をシェアする

ABOUT ME

author-image
Tomomitsu
2017年2月中途入社。業務ではAzureへのモダナイゼーションを行っています。Microsoft MVP for Developer Technologies 2022 -

© SAKURUG co.,ltd.