ブラウザサイド(Blazor Web Assembly)からの自作OIDCサーバの呼び出し(ASP.NET CORE)

概要

Blazor Web Assemblyからテスト用の自作OIDCサーバ(ASP.NET CORE MVC Controller製)を呼び出し、WebAPI用のaccess_tokenを取得し、Web API側でJwtBearerによる認証を行います。

テスト用なので、1つのプロジェクトに、WebAssemblyクライアント、Web APIサーバ、テスト用のOIDCサーバのソースを混ぜ込みます。

本ページはプログラムがメインなので、OIDCの概要や仕組みなどはASP.NET COREでOpenIdConnect(OIDC)を使う。ついでにテスト用のOIDCサーバを自作するをご参照ください。
また、OIDCでの各種チェックについてはIDトークンやアクセストークンでのなりすましの防止についてをご参照ください。

なお、サーバサイド(ASP.NET CORE MVC)のid_token認証用のプログラムを作る場合は、テスト用のOIDCサーバを自作するをご参照ください。

プロジェクト構成

プロジェクトをVisual Studio2022の「Blazor WebAssemblyアプリ」テンプレートで作成します。

認証の種類:個別アカウント
     (実際は個別アカウントは使いませんがテンプレートとして動作が近いため選択)
ASP.NET CORE Hosted :チェックする

Nugetは、以下のものをインストールします。
サーバプロジェクト:Microsoft.AspNetCore.Authentication.JwtBearer

そうして作成されたプロジェクトに、テスト用のOIDCサーバソースと、その呼び出し処理を加えると、以下のプロジェクト構成になります。
赤枠は、OIDCサーバソースで、
青枠は、OIDCサーバ呼び出しのために修正したソース
灰枠は、実際は不要なソースになります(テンプレートから作成した都合)。

Client、Server、Sharedの3つのプロジェクトが作成されますが、実体はServerプロジェクトになります。
Serverプロジェクトをビルドすると、依存しているClientとSharedもビルドされ、Serverプロジェクトが起動されます。
Serverプロジェクトの中で、Clientをブラウザに転送し、ブラウザ側でClientがWebAssemblyとして動作します。
ClientのWebAssemblyはデフォルトではServerプロジェクトとhttp通信する設定になっています。

テスト用のOIDCサーバプログラム

ここからは、プログラムを貼り付けていきます。プログラムの説明はプログラム中のコメントを参照するか、本ページの親ページASP.NET COREでOpenIdConnect(OIDC)を使うをご参照ください。
なお、OIDCサーバプログラムは、テスト用のOIDCサーバを自作するのものとほぼ同じです(namespaceが異なるくらい)。

AuthServerController.cs

テスト用OIDCサーバのWebAPI部分になります。これがメインの処理になります。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Policy;
using System.Text;
using System.Text.Json.Serialization;
using TestWebAssembly.Server.Models.AuthServer.ViewModel;
using TestWebAssembly.Shared.Models.AuthServer.JsonModel;

namespace TestWebAssembly.Server.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class AuthServerController : Controller
    {
        private IConfiguration _Configuration;

        // 発行者
        private string _OidcIssure;

        private string _OidcClientId;

        private string _OidcAuthority;
        private string _OidcServerAuthority;

        private string _OidcTokenTime;
        private string _OidcRefreshTime;

        // RSA秘密鍵 pem方式(Base64)
        // openssl genrsa -out private.pem 2048
        private string _OidcRsaPrivateKeyPem = "MIIEowIBAAKCAQEAv+lk5Z6Ht7OMj/Wu3V3BB2IXRAKzNLjlZjMiOZp71KrgtjEUVf5YH55jdvmXeDqH8gOpGcr/AnFWzXZggmt+vNSLrnf2Yy+jD7esuHQWjovvmi2N5RXsMfiPyGbqvLCjnRd+gDtqH/mX/sKyd0A9giht9LkQGS2xzKGszXU9SdQqd+/MoGBRAQNUdK4zx6azdw3JRYALVHB7trF4ISmbVT877cHXdAOnuR26Ttgtp2mEQgGmhwCmZRCJFLUiY9RcMZmFsQQ2aqjTOCiDmFy1hFMCKkSAU9tH9nKYX+V/r4QxUX0izdEBnKzzndyfHZ+ZNnYDIrHqFObwQ75+FHj8+wIDAQABAoIBAFaSds2OojKgcHxQnD2IGZe3DD/F6AmUjwd3ca0Cn3HkU9JceYwBXMeGr9/v4ACAhusJ87KK/FahwkKVcBvlWhrc1tYxj5hW2PwyI1xiIfrT7ZZjjmsVZKN2OYZxxtqv5F7tRkeahdk+wu5N7iwVcqnQiymmgjiZgGeLV2SnyqoOne7TDRBsIxuuQQVGx1KbnyGFdZ8GwwszNCntRTbs7tBDmgkjC0yF0MFwnrWHa7JIMt2eNhBHPW7lutfcW59EKRAtFfZeulCh6bYyQlfr4CrkVm2VwHJ93/OtrneDsdnFiyAS485cR3NLHeEt5a1hzsK9gBw2Q8gQbfF6xcQJMMECgYEA/itA90WxnMZ/xBb5S160/6zA+H7x02ts/2l04It2BkeF/xx2on+ZvogAEx/XIdkhxu/LjGDczTKRx3RcmqkK0cIDlUokgKBXdLZim4KpppRjoLO4XzALYV8c4kQLyhZJstr0LbB46+gJgJy9AOKfUzU57LJ6e2xjlPot5Wuve+kCgYEAwUtS1tkhdaQ/fyfZKtTHA8qJk2UnXTJsi1MCM7m0gJuKQBEnj9hiCYyLfByPFKh1NSYBbCWtsBCgr22lAzZ6FM6Bn5leREqTXdAw3C98DfGGJJb3PkRrd719hmdW+oNPBpsg6OljZTNaHPbWnSDa1HX+Kso3gGgfIprrl/E4t0MCgYB9NJmANdjUvgPaeOa9dh37hQJaZ06BM34yI6TrqEevuDQOA9t9GRaHgT9oLFsx1WCKOz4uHNkoTCz78BpeJb+qrMRPGoL4ygPK/r18ldU0tgyN8Xp1iZlRkiUMYTDdkDUl4i4/A5vNujRUIuIIOZr6xlGOhC1J947mqIdLaMy9YQKBgF7umonQbCF2AIV30a26gN+4ymasqG+aQzsOEqfKzf7X5Udf5Xk9QbEE6MCU2iTAM4hd2Rb/TlWJZRGjnQZ96+lqHrl+vpB1u/i4Njb9z0Sd2U8BIf7f5ZQSaaLoAAzbDqXk7H3Xmixq0xgklvTm3PK53JGxbR7QhHIfehPOshc3AoGBAI4c4sJZ7jU7Af5Q79S+9XCtkk5SFSgzuMwclaqjTBPjw9nsQO3sNgHQtSIDLy/CKpcjZt33ROfGBp41IBRP/tFFJFbTE6RSrXhlPiQVT9SThvxo0nJPSs95G2UN+eFpTzM2+nYLyg7tzxVPa76HDgghCflHj8+qhEb8+oEs0yeC";

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="configuration">設定</param>
        public AuthServerController(IConfiguration configuration)
        {
            _Configuration = configuration;
            // 本来は設定ファイルから取得するが
            // テストサーバの用途的に、固定で書くことにする

            // 発行者
            _OidcIssure = "oidc_server";
            //_OidcIssure = "AuthServer";

            // Tokenを発行する対象のクライアント
            _OidcClientId = "test_client";
            // Blazor Web Assemblyを想定したURL
            // 相対パスで書くと、ブラウザ側のベースURL + 相対パスでアクセスしてくれる
            _OidcAuthority = "AuthServer";
            // Token有効期限(秒) 1日(=24H)
            //_OidcTokenTime = "86400";
            _OidcTokenTime = "86400";
            // Tokenリフレッシュ期限(秒) 1日(=24H)
            //_OidcRefreshTime = "86400";
            _OidcRefreshTime = "120";
            // 後で設定する
            _OidcServerAuthority = "";
        }

        /// <summary>
        /// メタ情報取得
        /// id_token取得用のURLなどのメタ情報を返却する
        /// </summary>
        /// <returns>メタ情報</returns>
        [HttpGet(".well-known/openid-configuration")]
        public OidcWellKnown MetaDataByWebAssembly()
        {
            OidcWellKnown oidcWellKnown = new OidcWellKnown();

            oidcWellKnown.issuer = _OidcIssure;
            // 相対パスで書くと、ブラウザ側のベースURL + 相対パスでアクセスしてくれる
            oidcWellKnown.authorization_endpoint = _OidcAuthority + "/Auth";
            oidcWellKnown.token_endpoint = _OidcAuthority + "/Token";
            oidcWellKnown.userinfo_endpoint = _OidcAuthority + "/UserInfo";
            oidcWellKnown.jwks_uri = _OidcAuthority + "Jwks";
            //oidcWellKnown.end_session_endpoint = _OidcAuthority + "/Logout";
            // リフレッシュトークンとイントロスペクションは未実装

            oidcWellKnown.response_types_supported = new List<string>();
            oidcWellKnown.response_types_supported.Add("code");
            oidcWellKnown.response_types_supported.Add("id_token");
            oidcWellKnown.response_types_supported.Add("token");
            oidcWellKnown.response_types_supported.Add("token id_token");

            return oidcWellKnown;
        }

        /// <summary>
        /// メタ情報取得(Webサーバ -> OIDCサーバのAJax用)
        /// ※WebAssembly(ブラウザ)でaccess_tokenを利用する際に使用する
        /// access_tokenの場合はWebサーバでJWTのチェックのためにOIDCサーバにアクセス
        /// するが、リダイレクトではなく直接参照するため、リバースプロキシ前のURL
        /// でアクセスすると(大抵Webサーバはコンテナで動いているので)アクセスできない
        /// なので、localhostに対してアクセスする
        /// (kestrelで実行する際に、httpsとは別にサーバ用にhttpもポーリングしておく)
        /// </summary>
        /// <returns></returns>
        [HttpGet("MetaDataByWebServer")]
        public OidcWellKnown MetaDataByWebServer()
        {
            // 本来はhttp://localhost:ポート/ でアクセスするが、今回は割愛
            // ※リバースプロキシを使わない
            string baseUrl = Request.Scheme + "://" 
                + this.Request.Host.Value + Request.PathBase;
            if (baseUrl.EndsWith("/"))
            {
                baseUrl = baseUrl.TrimEnd('/');
            }
            // アプリサーバからアクセスする場合のURL
            // (Blazorのサーバ側のBearerがアクセスする。またMVC側のサーバがアクセスする)
            _OidcServerAuthority = baseUrl + "/AuthServer";

            OidcWellKnown oidcWellKnown = new OidcWellKnown();

            oidcWellKnown.issuer = _OidcIssure;
            oidcWellKnown.authorization_endpoint = _OidcServerAuthority + "/Auth";
            oidcWellKnown.token_endpoint = _OidcServerAuthority + "/Token";
            oidcWellKnown.userinfo_endpoint = _OidcServerAuthority + "/UserInfo";
            oidcWellKnown.jwks_uri = _OidcServerAuthority + "/Jwks";
            //oidcWellKnown.end_session_endpoint = _OidcServerAuthority + "/Logout";
            // リフレッシュトークンとイントロスペクションは未実装

            oidcWellKnown.response_types_supported = new List<string>();
            oidcWellKnown.response_types_supported.Add("code");
            oidcWellKnown.response_types_supported.Add("id_token");
            //oidcWellKnown.response_types_supported.Add("token");
            //oidcWellKnown.response_types_supported.Add("token id_token");

            return oidcWellKnown;
        }

        /// <summary>
        /// 認可コード(code) Or id_token取得
        /// 詳細は本ページトップの概要を参照のこと
        /// </summary>
        /// <param name="responseType">レスポンスタイプ code or id_token</param>
        /// <param name="clientId">クライアントID</param>
        /// <param name="redirectUri">コールバック先のURL</param>
        /// <param name="state">state</param>
        /// <param name="scope">スコープ</param>
        /// <param name="nonce">nonce</param>
        /// <param name="prompt">プロンプト</param>
        /// <param name="codeChallenge">コードチャレンジ</param>
        /// <param name="codeChallengeMethod">コードチャレンジメソッド</param>
        /// <returns></returns>
        [HttpGet("Auth")]
        public IActionResult Auth(
            [FromQuery(Name = "response_type")] string responseType
            , [FromQuery(Name = "client_id")] string? clientId
            , [FromQuery(Name = "redirect_uri")] string redirectUri
            , [FromQuery(Name = "state")] string state
            , [FromQuery(Name = "scope")] string? scope
            , [FromQuery(Name = "nonce")] string? nonce
            , [FromQuery(Name = "prompt")] string? prompt
            , [FromQuery(Name = "code_challenge")] string? codeChallenge
            , [FromQuery(Name = "code_challenge_method")] string? codeChallengeMethod)
        {
            // code_challenge : Base64の文字列
            // code_challenge_method : S256
            // response_mode : form_post

            bool isError = false;

            // client_id
            if (_OidcClientId != clientId)
            {
                // client_id不一致時はエラー
                isError = true;
            }
            if (isError)
            {
                Request.Method = "GET";
                Response.StatusCode = 302;
                return new RedirectResult(
                    redirectUri + "?error=access_denied" + "&state=" + state
                    , permanent: false, preserveMethod: false);
            }


            // response_typeはcodeとid_tokenがある
            // id_tokenが必要な場合はid_tokenで呼び出し、
            // access_tokenが必要な場合はcodeを呼出し後、tokenを呼び出す。
            // なお、id_tokenが必要な場合でもcodeの呼び出し後tokenの呼び出しで取得できる。
            if (responseType != null && responseType == "code")
            {
                if (prompt == "none")
                {
                    // promptによるcodeの呼ばれ方は2種類あり
                    // prompt = noneの場合はログイン画面を表示せずに認可コードを返却する
                    // すでに他のサイトでログインしている前提で、本来はOpenIdConnectサーバは
                    // cookie等を使ってユーザーが他のサイトでシングルサインオンしているかを判断し
                    // シングルサインオンしている場合はログイン画面の表示なしで認可コードを返却する
                    // なお、ASP.NET COREでは、prompt=noneはAJax(WebAPI)で呼び出される

                    // シングルサインオンしていない場合はエラーを返す
                    Request.Method = "GET";
                    Response.StatusCode = 302;
                    return new RedirectResult(redirectUri + "?error=access_denied"
                        + "&state=" + state, permanent: false, preserveMethod: false);
                }
                else
                {
                    // 本来はセッションを確認してすでにログインしている場合は認可コードを返却する

                    // ログイン画面を表示して認証する
                    AuthServerViewModel viewModel = new AuthServerViewModel()
                    {
                        // 本来はセッション等に格納するが、テスト用のためhiddenで持ち回る
                        ClientId = clientId,
                        RedirectUri = redirectUri,
                        State = state,
                        CodeChallenge = codeChallenge,
                        CodeChallengeMethod = codeChallengeMethod,
                        Nonce = nonce,
                        ResponseType = responseType
                    };
                    return View("AuthLogin", viewModel);
                }
            }
            else
            {
                // id_tokenで呼び出された場合、codeと同様にログイン処理を行う
                // ログイン処理後、codeを返却するのではなく、id_tokenを返却する

                // ログイン画面を表示して認証する
                AuthServerViewModel viewModel = new AuthServerViewModel()
                {
                    // 本来はセッション等に格納するが、テスト用のためhiddenで持ち回る
                    ClientId = clientId,
                    RedirectUri = redirectUri,
                    State = state,
                    CodeChallenge = codeChallenge,
                    CodeChallengeMethod = codeChallengeMethod,
                    Nonce = nonce,
                    ResponseType = responseType
                };
                return View("AuthLogin", viewModel);

            }
        }

        /// <summary>
        /// ログイン処理
        /// </summary>
        /// <param name="clientId">クライアントID</param>
        /// <param name="redirectUri">コールバックURL</param>
        /// <param name="state">state</param>
        /// <param name="codeChallenge">コードチャレンジ</param>
        /// <param name="codeChallengeMethod">コードチャレンジメソッド</param>
        /// <param name="nonce">nonce</param>
        /// <param name="responseType">レスポンスタイプ</param>
        /// <param name="userId">ユーザID</param>
        /// <returns></returns>
        [HttpPost("AuthLogin")]
        public IActionResult AuthLogin(
            [FromForm(Name = "ClientId")] string? clientId
            , [FromForm(Name = "RedirectUri")] string? redirectUri
            , [FromForm(Name = "State")] string? state
            , [FromForm(Name = "CodeChallenge")] string? codeChallenge
            , [FromForm(Name = "CodeChallengeMethod")] string? codeChallengeMethod
            , [FromForm(Name = "Nonce")] string? nonce
            , [FromForm(Name = "ResponseType")] string? responseType
            , [FromForm(Name = "UserId")] string? userId)
        {
            // codeにuserIdとstateを格納しておく(Tokenで使うため)
            // 本来はcodeには適当な一意なIDを設定し、
            // サーバ内でそのIDに紐づける形でuserIdとstateを保持する。
            string code = $"{userId},{state},{codeChallenge},{codeChallengeMethod},{nonce}";
            if (responseType == "code")
            {
                if (!string.IsNullOrEmpty(userId))
                {
                    return new RedirectResult(
                        redirectUri + $"?code={code}&state={state}", false, false);
                }
                else
                {
                    return new RedirectResult(
                        redirectUri + $"?error=access_denied&state={state}", false, false);
                }
            }
            else
            {
                if (!string.IsNullOrEmpty(userId))
                {

                    // id_token
                    OidcTokens token = CreateToken(userId, state, nonce);

                    // id_tokenをpostで返却したいところだが、Formにid_tokenを追加する方法がわからない。
                    // 以下はうまくいかなかった例
                    //Dictionary<string, StringValues> formDic = new Dictionary<string, StringValues>();
                    //formDic.Add("id_token", new StringValues(token.id_token));
                    //formDic.Add("state", new StringValues(token.session_state));
                    //formDic.Add("redirectUri", new StringValues(redirectUri));
                    //FormCollection form = new FormCollection(formDic);
                    //Request.Form = form;

                    // なのでいったんid_tokenを含んだ確認画面を表示し、確認画面からリダイレクトする
                    AuthServerViewModel viewModel = new AuthServerViewModel()
                    {
                        // 本来はセッション等に格納するが、テスト用のためhiddenで持ち回る
                        ClientId = clientId,
                        RedirectUri = redirectUri,
                        State = state,
                        Nonce = nonce,
                        ResponseType = responseType,
                        IdToken = token.id_token,
                        Error = ""
                    };
                    return View("AuthLoginConfirm", viewModel);
                }
                else
                {
                    AuthServerViewModel viewModel = new AuthServerViewModel()
                    {
                        // 本来はセッション等に格納するが、テスト用のためhiddenで持ち回る
                        ClientId = clientId,
                        RedirectUri = redirectUri,
                        State = state,
                        Nonce = nonce,
                        ResponseType = responseType,
                        IdToken = "",
                        Error = "access_denied"
                    };
                    return View("AuthLoginConfirm", viewModel);
                }
            }
        }

        /// <summary>
        /// ログイン確認画面からのサブミット
        /// </summary>
        /// <param name="redirectUri">コールバックURL</param>
        /// <returns></returns>
        [HttpPost("AuthLoginRedirect")]
        public IActionResult AuthLoginRedirect(
            [FromForm(Name = "RedirectUri")] string? redirectUri)
        {
            // Postで返却する
            Request.Method = "POST";
            Request.ContentType = "application/x-www-form-urlencoded";
            return new RedirectResult(redirectUri, true, true);

        }

        /// <summary>
        /// Token取得
        /// Token取得はgrant_typeにより2つの形式で呼び出される
        /// grant_typeが"refresh_token"の場合は、トークン有効期限切れのために
        /// Tokenが呼び出される。
        /// パラメータとして本処理で返却したrefresh_tokenが渡される
        /// </summary>
        /// <param name="redirectUri">コールバックURL</param>
        /// <param name="clientId">クライアントID</param>
        /// <param name="code">code</param>
        /// <param name="refreshToken">リフレッシュトークン</param>
        /// <param name="grantType">グラントタイプ</param>
        /// <returns></returns>
        [HttpPost("Token")]
        public OidcTokens Token(
            [FromForm(Name = "redirect_uri")] string? redirectUri
            , [FromForm(Name = "client_id")] string? clientId
            , [FromForm(Name = "code")] string? code
            , [FromForm(Name = "code_verifier")] string? codeVerifier
            , [FromForm(Name = "refresh_token")] string? refreshToken
            , [FromForm(Name = "grant_type")] string? grantType)
        {

            bool isError = false;

            // client_id
            if (_OidcClientId != clientId)
            {
                // client_id不一致時はエラー
                isError = true;
            }

            // 今回はテスト用のため、useridとstateをcode(またはrefresh_token)から取得する
            // ※本当はcodeにはキー情報を設定し、認証したユーザIDなどはセッション等に格納しておく
            // ※今回はrefresh_tokenにもcodeと同じ値を設定しているが、実際はid_tokenを設定し、
            // id_tokenの署名の検証を行ったのち、id_tokenからユーザIDを取得する
            string userId = "";
            string state = "";
            string codeChallenge = "";
            string codeChallengeMethod = "";
            string nonce = "";


            string? authCode;
            if (grantType == "refresh_token")
            {
                // トークンの有効期限切れのため再度Tokenが呼び出された
                authCode = refreshToken;
            }
            // authorization_code
            else
            {
                // 通常
                authCode = code;
            }

            if (!isError)
            {
                if (!string.IsNullOrEmpty(authCode))
                {
                    // テストなので、authCodeには
                    // userid,state,code_challenge,code_challenge_method,nonce
                    // の形式でデータを設定している
                    string[] work = authCode.Split(",");
                    userId = work[0];
                    // code -> tokenの流れの場合は、tokenにはstateを設定しない
                    //state = work[1];
                    state = "";
                    codeChallenge = work[2];
                    codeChallengeMethod = work[3];
                    nonce = work[4];
                }
                // useridが取得できなかった場合
                if (string.IsNullOrEmpty(userId))
                {
                    isError = true;
                }

            }

            if (!isError)
            {
                if (!string.IsNullOrEmpty(codeChallenge) 
                    && !string.IsNullOrEmpty(codeVerifier))
                {
                    // code_challengeの確認
                    // .netのcode_challenge_methodはS256なので、
                    // 手を抜いてcode_challenge_methodの判定を行わずS256で判定する
                    var sha256 = SHA256.Create();
                    byte[] codeVerifierHashByte = sha256.ComputeHash(
                        Encoding.UTF8.GetBytes(codeVerifier));
                    string codeVerifierBase64UInt = ConvertToBase64UInt(codeVerifierHashByte);
                    if (codeChallenge != codeVerifierBase64UInt)
                    {
                        // 一致しなかったらエラー
                        isError = true;
                    }
                }
            }

            OidcTokens token;
            if (!isError)
            {
                token = CreateToken(userId, state, nonce);
                Request.Method = "GET";
                // Jsonで返却する(ASP.NETがModelを勝手にJson形式に変換してくれる)
                Response.ContentType = "application/json";
                Response.StatusCode = 200;
            }
            else
            {
                token = new OidcTokens();
                // 認証エラー
                Response.ContentType = "application/json";
                Response.StatusCode = 400;
                token.error = "invalid_grant";
                token.error_description = "user invalid";
                token.error_uri = "";
            }
            return token;
        }

        /// <summary>
        /// トークン電文作成
        /// </summary>
        /// <param name="userId">ユーザID</param>
        /// <param name="state">state</param>
        /// <param name="nonce">state</param>
        /// <returns>トークン電文モデル</returns>
        private OidcTokens CreateToken(string userId, string state, string nonce)
        {

            OidcTokens token = new OidcTokens();
            // 秘密鍵を生成する

            // RSA pem方式(Base64)
            // openssl genrsa -out private.pem 2048
            int readByte;
            using RSA rsa = RSA.Create();
            rsa.ImportRSAPrivateKey(Convert.FromBase64String(_OidcRsaPrivateKeyPem)
                , out readByte);

            // Claim(認証情報)の作成
            ClaimsIdentity claimsIdentity = new ClaimsIdentity();
            // ユーザID(一意に特定するためのIDで必須)
            claimsIdentity.AddClaim(new Claim("sub", userId));
            // ユーザの表示名で任意
            claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, userId));
            //claimsIdentity.AddClaim(new Claim("role", "Admin"));
            // nonce
            claimsIdentity.AddClaim(new Claim("nonce", nonce));
            // nonce
            //claimsIdentity.AddClaim(new Claim("scope", "openid email profile"));

            // JWTを生成する
            SecurityTokenDescriptor descriptor = new SecurityTokenDescriptor();

            // Audience Tokenを発行する対象のクライアント
            descriptor.Audience = _OidcClientId;
            // Tokenの発行者
            descriptor.Issuer = _OidcIssure;
            // Claim
            descriptor.Subject = claimsIdentity;
            // Tokenが有効になる日時(省略時は今すぐ)
            descriptor.NotBefore = DateTime.Now;
            // Tokenの有効期限(日時)
            // Tokenの有効期限はTokenVaidateでチェックすると思われる
            // Blazor WebAssemblyのブラウザサイドではチェックしていないようにみえる
            // Blazor WebAssemblyのサーバサイド(JwtBearer)はValidateLifetime=trueにすることで
            // チェックする?(未検証)
            // Blazor WebAssemblyのブラウザサイドでは、Token返却に一緒に返却する
            // expires_inをチェックしている
            // expires_inが期限切れになると、Tokenのrefreshの電文が
            // Blazor WebAssemblyのブラウザから発行される
            descriptor.Expires = DateTime.Now.AddSeconds(double.Parse(_OidcTokenTime));
            // 発行日
            descriptor.IssuedAt = DateTime.Now;
            // Jwtの暗号化(今回は行わない)
            // Jwtには署名がつくが、ユーザIDなどの認証情報は平文(Base64)のままである。
            // 署名は情報を隠すためではなく、改ざん防止のためにある
            descriptor.EncryptingCredentials = null;

            descriptor.SigningCredentials = new SigningCredentials(
                // RSAキー
                key: new RsaSecurityKey(rsa)
                {
                    // 完全に参照元のサイトの受け売りだが、
                    // 破棄されないキャッシュがあるらしく
                    // 偶数リクエストで失敗するらしい
                    // そのため明示的にキャッシュをクリアするそうな
                    // TODO:後でキャッシュのクリアを外して確認する
                    CryptoProviderFactory = new CryptoProviderFactory()
                    {
                        CacheSignatureProviders = false
                    }
                },
                algorithm: SecurityAlgorithms.RsaSha256
            );

            // 上記の内容でTokenを作成する
            JwtSecurityTokenHandler jwtHandler = new JwtSecurityTokenHandler();
            var securityToken = jwtHandler.CreateJwtSecurityToken(descriptor);
            //var securityToken = jwtHandler.CreateToken(descriptor);
            // JwtSecurityTokenでないとヘッダを追加できない
            securityToken.Header.Add("kid", _OidcClientId);
            string tokenStr = jwtHandler.WriteToken(securityToken);
            //var tokenStr = jwtHandler.CreateEncodedJwt(descriptor);

            // Token電文を生成する
            token.access_token = tokenStr;
            token.id_token = tokenStr;
            token.scope = "openid email profile";
            token.expires_in = _OidcTokenTime;
            // OpenIdConnectの場合はBearer固定
            // このトークンを持っている=認可されているという意味になるそう
            token.token_type = "Bearer";
            //token.token_type = "BearerToken";
            // クライアントがid_tokenを要求した際に送信してきたstateをそのまま返す
            // クライアントはこれでリクエストに対するリダイレクトであると判定する
            // ただし、code -> token時はtokenでstateを返してはいけない
            if (string.IsNullOrEmpty(state))
            {
                token.state = null;
            }
            else
            {
                token.state = state;
            }

            // Tokenが有効になるまでの時間(秒)
            // TokenのNotBeforeに加算されて使われる
            token.not_before_policy = "0";
            
            // Token有効期限切れの際にクライアントからOidCサーバへの
            // Token電文のrefresh_tokenに設定される値

            // 今回は手抜きで、codeと同じく、userIdとstateを設定しておく。
            // 本来は、useridとstateをセッションに格納し、セッションに紐づく一意なキーを返却する
            string code = $"{userId},{state},,,";
            token.refresh_token = code;

            // Refresh時間(秒)
            // Expires_inよりも短く設定した場合の検証はやっていない
            token.refresh_expires_in = _OidcRefreshTime;

            return token;

        }

        /// <summary>
        /// ユーザ情報取得
        /// </summary>
        /// <returns></returns>
        [HttpGet("UserInfo")]
        [Authorize]
        public OidcUserInfo UserInfo()
        {

            OidcUserInfo userInfo = new OidcUserInfo();

            userInfo.name = "dummy";
            userInfo.iss = _OidcIssure;
            userInfo.aud = _OidcClientId;
            userInfo.sub = this.User.Identity?.Name;
            Response.ContentType = "application/json";
            Response.StatusCode = 200;
            return userInfo;
        }

        /// <summary>
        /// ログアウト(未実装)
        /// </summary>
        /// <returns></returns>
        [HttpGet("Logout")]
        public string Logout()
        {
            return "";
        }

        /// <summary>
        /// ログイン失敗用
        /// </summary>
        /// <returns></returns>
        [HttpGet("Fail")]
        public IActionResult Fail()
        {
            return this.Challenge();
        }

        /// <summary>
        /// 公開鍵情報取得
        /// </summary>
        /// <returns></returns>
        [HttpGet("Jwks")]
        public OidcKeys Jwks()
        {
            // RSA公開鍵の指数(e)とModulus(n)の作り方
            // 秘密鍵をもとに公開鍵を作る
            // openssl rsa -in private.pem -outform PEM -pubout -out public.pem
            // 公開鍵から指数とModulusを作る
            // openssl rsa -in public.pem -pubin -text -noout
            // (modulusは最初の00:を除く)
            // (指数は出力された数字をBASE64変換して使う)
            // modulusは以下のコマンドで出力する(openssl --modulusで16進数文字列で出力されるため
            // それをBase64UrlUIntに変換する)
            // openssl rsa -in public.pem -pubin -noout -modulus | cut -d= -f2 | xxd -r -p | base64 -w 0

            // 秘密鍵から公開鍵のModulus(n)と指数(e)を生成する
            int readByte;
            using RSA rsa = RSA.Create();
            rsa.ImportRSAPrivateKey(Convert.FromBase64String(_OidcRsaPrivateKeyPem)
                , out readByte);
            var publicParam = rsa.ExportParameters(includePrivateParameters: false);

            //ModulusはBase64UrlUIntに変換する
            //通常のBase64に加え、+を-, /を_に置換し、末尾の==を除去したもの

            // RSAParametersのModulusをバイト配列に変換
            var modulusBytes = publicParam.Modulus;

            // バイト配列をBase64UIntにエンコード
            var modulusBase64UInt = ConvertToBase64UInt(modulusBytes);

            // 指数
            var exponentBase64UInt = ConvertToBase64UInt(publicParam.Exponent);


            OidcKeyInfo keyInfo = new OidcKeyInfo();
            keyInfo.kty = "RSA";
            keyInfo.alg = SecurityAlgorithms.RsaSha256;
            keyInfo.use = "sig";
            // 指数 65537をBase64変換したもの
            keyInfo.e = exponentBase64UInt;
            //keyInfo.e = "65537";
            // Modulus
            keyInfo.n = modulusBase64UInt;
            // このキーIDとtokenのkidは合わせる
            keyInfo.kid = _OidcClientId;

            OidcKeys keys = new OidcKeys();
            keys.Keys = new List<OidcKeyInfo>();
            keys.Keys.Add(keyInfo);
            //Microsoft.IdentityModel.Tokens.JsonWebKey aa = new JsonWebKey();
            return keys;
        }

        /// <summary>
        /// Base64UInt変換
        /// </summary>
        /// <param name="data">バイト</param>
        /// <returns>Base64UInt文字列</returns>
        private string ConvertToBase64UInt(byte[] data)
        {
            // バイト配列をBase64にエンコード
            var dataBase64 = Convert.ToBase64String(data);

            // Base64の文字列から、+と/をそれぞれ-と_に置き換え
            var dataBase64UInt = dataBase64.Replace('+', '-').Replace('/', '_');

            // Base64の文字列の末尾の=を削除
            dataBase64UInt = dataBase64UInt.TrimEnd('=');

            return dataBase64UInt;
        }

    }
}

OidcKeyInfo.cs

公開鍵情報返却用です。.NETがJSON形式にして返却します。

namespace TestWebAssembly.Shared.Models.AuthServer.JsonModel
{
    public class OidcKeyInfo
    {
        public string? kty { get; set; }
        public string? alg { get; set; }
        public string? use { get; set; }
        public string? n { get; set; }
        public string? e { get; set; }
        public string? kid { get; set; }

    }
}

OidcKeys.cs

公開鍵情報返却用。公開鍵情報は複数返却することができるので、配列構造になっています。

namespace TestWebAssembly.Shared.Models.AuthServer.JsonModel
{
    public class OidcKeys
    {
        public List<OidcKeyInfo> Keys { get; set; }
    }
}

OidcTokens.cs

トークン返却用。

using System.Text.Json.Serialization;

namespace TestWebAssembly.Shared.Models.AuthServer.JsonModel
{
    public class OidcTokens
    {
        public string? access_token { get; set; }
        public string? expires_in { get; set; }
        public string? refresh_expires_in { get; set; }
        public string? refresh_token { get; set; }
        public string? token_type { get; set; }
        public string? id_token { get; set; }
        [JsonPropertyName("not-before-policy")]
        public string? not_before_policy { get; set; }
        public string? state { get; set; }
        public string? scope { get; set; }
        public string? error { get; set; }
        public string? error_description { get; set; }
        public string? error_uri { get; set; }

    }
}

OidcUserInfo.cs

ユーザ情報返却用。

namespace TestWebAssembly.Shared.Models.AuthServer.JsonModel
{
    public class OidcUserInfo
    {
        public string? sub { get; set; }
        public string? iss { get; set; }
        public string? aud { get; set; }
        public string? name { get; set; }
    }
}

OidcWellKnown.cs

メタ情報返却用。

namespace TestWebAssembly.Shared.Models.AuthServer.JsonModel
{
    public class OidcWellKnown
    {
        public string? issuer { get; set; }
        public string? authorization_endpoint { get; set; }
        public string? token_endpoint { get; set; }
        public string? jwks_uri { get; set; }
        public List<string>? response_types_supported { get; set; }
        public string? introspection_endpoint { get; set; }
        public string? userinfo_endpoint { get; set; }
        public string? end_session_endpoint { get; set; }

    }
}

AuthServerViewModel.cs

Oidcのログイン画面で入力されたユーザIDや、ユーザIDをもとに生成したトークンをHTMLのHidden項目で持ち回るためのモデル。

#nullable enable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel;

namespace TestWebAssembly.Server.Models.AuthServer.ViewModel
{
    public class AuthServerViewModel
    {
        /// <summary>
        /// エラー
        /// </summary>
        [DisplayName("エラー")]
        public string? Error { get; set; }

        /// <summary>
        /// ユーザーID
        /// </summary>
        [DisplayName("ユーザーID")]
        public string? UserId { get; set; }

        /// <summary>
        /// クライアントID(持ち回り用)
        /// </summary>
        public string? ClientId { get; set; }

        /// <summary>
        /// State(持ち回り用)
        /// </summary>
        [DisplayName("State")]
        public string? State { get; set; }

        /// <summary>
        /// nonce(持ち回り用)
        /// </summary>
        public string? Nonce { get; set; }

        /// <summary>
        /// code_challenge(持ち回り用)
        /// </summary>
        public string? CodeChallenge { get; set; }

        /// <summary>
        /// code_challenge_method(持ち回り用)
        /// </summary>
        public string? CodeChallengeMethod { get; set; }

        /// <summary>
        /// リダイレクト(持ち回り用)
        /// </summary>
        public string? RedirectUri { get; set; }

        /// <summary>
        /// レスポンスタイプ(持ち回り用)
        /// </summary>
        public string? ResponseType { get; set; }
        /// <summary>
        /// idトークン(持ち回り用)
        /// </summary>
        [DisplayName("IDトークン")]
        public string? IdToken { get; set; }

    }
}

AuthLogin.cshtml

OIDCのログイン画面。
表示すると、CSSが無効になっていますが、正常です(テスト用なのでbootstrapを入れていません)。
気になる方はbootstrapを入れてください(サーバサイド用のwwwrootをコピーするのが速いと思います)。

@model TestWebAssembly.Server.Models.AuthServer.ViewModel.AuthServerViewModel

@{
    ViewBag.Title = "ログイン画面";
}

<div class="form-horizontal">

    @using (Html.BeginForm("AuthLogin", "AuthServer"))
    {
        @Html.AntiForgeryToken()

        @Html.HiddenFor(model => model.RedirectUri)
        @Html.HiddenFor(model => model.ClientId)
        @Html.HiddenFor(model => model.State)
        @Html.HiddenFor(model => model.Nonce)
        @Html.HiddenFor(model => model.CodeChallenge)
        @Html.HiddenFor(model => model.CodeChallengeMethod)
        @Html.HiddenFor(model => model.ResponseType)

        <div class="form-group">
            @Html.ValidationSummary(true, "", new { @class = "text-danger" })
        </div>

        <div>
            ユーザIDを入力しなければ、認証エラーになります。
        </div>

        <div class="form-group">
            @Html.LabelFor(model => model.UserId
        , htmlAttributes: new { @class = "control-label col-xs-3" })
            <div class="col-xs-8">
                @Html.EditorFor(model => model.UserId
            , new { htmlAttributes = new { @class = "form-control" } })
            </div>
        </div>
        <div class="form-group">
            <div class="col-xs-offset-3 col-xs-8">
                <input type="submit" value="ログイン" class="btn btn-default" />
            </div>
        </div>

    }
</div>

AuthLoginConfirm.cshtml

OIDCのログイン確認画面。id_tokenはPOSTで返却する必要があるが、ログイン画面からダイレクトでPOSTする方法がわからなかったので、ログイン確認画面にトークンを埋め込んで、確認画面のサブミットでPOSTでWEBサーバにリダイレクトされるようにしています。

@model TestWebAssembly.Server.Models.AuthServer.ViewModel.AuthServerViewModel

@{
    ViewBag.Title = "ログイン確認画面";
}

<div class="form-horizontal">

    @using (Html.BeginForm("AuthLoginRedirect", "AuthServer"))
    {
        @Html.AntiForgeryToken()

        @Html.HiddenFor(model => model.RedirectUri)
        @Html.Hidden("state", Model.State)
        @if (string.IsNullOrEmpty(Model.Error))
        {
            @Html.Hidden("id_token", Model.IdToken)
        }
        else
        {
            @Html.Hidden("error", Model.Error)
        }

        <table>
            <tr>
                <td>
                    <div class="form-group">
                        @Html.LabelFor(m => m.IdToken)
                        @Html.DisplayFor(m => m.IdToken)
                    </div>
                </td>
            </tr>
            <tr>
                <td>
                    <div class="form-group">
                        @Html.LabelFor(m => m.State)
                        @Html.DisplayFor(m => m.State)
                    </div>
                </td>
            </tr>
            <tr>
                <td>
                    <div class="form-group">
                        @Html.LabelFor(m => m.Error)
                        @Html.DisplayFor(m => m.Error)
                    </div>
                </td>
            </tr>
            <tr>
                <td>
                    <div class="form-group">
                        <div class="col-xs-offset-3 col-xs-8">
                            <input type="submit" value="IDトークン送信" class="btn btn-default" />
                        </div>
                    </div>
                </td>
            </tr>
        </table>
    }
</div>

_Layout.cshtml

MVCモデルのViewでログイン画面を作成しているので、_Layout.cshtmlを追加しています。ASP.NET CORE MVCのプロジェクトからコピーするのが速いと思います(下記のソースはMVCプロジェクトのコピーです)。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - TestWebSv</title>
    <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/TestWebSv.styles.css" asp-append-version="true" />
</head>
<body>
    <header>
        <nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
            <div class="container-fluid">
                <a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">TestWebSv</a>
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse" aria-controls="navbarSupportedContent"
                        aria-expanded="false" aria-label="Toggle navigation">
                    <span class="navbar-toggler-icon"></span>
                </button>
                <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
                    <ul class="navbar-nav flex-grow-1">
                        <li class="nav-item">
                            <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
                        </li>
                    </ul>
                </div>
            </div>
        </nav>
    </header>
    <div class="container">
        <main role="main" class="pb-3">
            @RenderBody()
        </main>
    </div>

    <footer class="border-top footer text-muted">
        <div class="container">
            © 2023 - TestWebSv - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
        </div>
    </footer>
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>

    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

_ValidationScriptsPartial.cshtml

これもMVCからのコピーです。
実際のところ、JQueryインストールしていないので意味はありません。サーバサイドのプログラムと合わせるためだけに作成しています。

<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

_ViewImports.cshtml

これもMVCからのコピーです。

@using TestWebAssembly.Server
@using TestWebAssembly.Server.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

_ViewStart.cshtml

これもMVCからのコピーです。

@{
    Layout = "_Layout";
}

WEBクライアントの設定

BlazorWebAssembly(Client)からOIDCサーバを呼び出すプログラムになります。

Client/Program.csに「AddOidcAuthentication」を追加することでOIDCサーバと通信を行います。
実際の処理は、テンプレートでプロジェクトを作成した際に作られるClient/Pages/Authentication.razorが行っています。

今回は、CounterページとFetchDataページにアクセス権限を設定します。
認証に成功すると「Administrator」ロールが設定されます(サンプルなのでハードコーディング。本当はアプリでaccess_tokenをもとにユーザIDを特定し、DBなどからロールを取得します)。

ロールは、OIDCサーバではなくWebクライアントで設定します。
AddOpenIdConnectにAddAccountClaimsPrincipalFactoryを追加して、その引数にCustomUserFactoryを渡し、CustomUserFactoryの中でロールを取得します。

FetchDataページは、「Administrator」ロールで参照可能なようにし、Counterページは「Administrator2」ロールで参照可能なようにします。
結果、FetchDataページは参照できて、Counterページは参照できない、という結果になります。

Client/Program.cs

AddOpenIdConnectを追加し、OIDCによる認証を行うようにします。
また、OIDCから結果が返却されたら、CustomUserFactoryでロールの付与を行います。

ing Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using TestWebAssembly.Client;
using System.Security.Claims;
using TestWebAssembly.Client.Models;

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

builder.Services.AddHttpClient("TestWebAssembly.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("TestWebAssembly.ServerAPI"));

builder.Services.AddOidcAuthentication<RemoteAuthenticationState
    , CustomUserAccount>(options =>
{
    options.ProviderOptions.Authority = "oidc_server";
    options.ProviderOptions.MetadataUrl = "AuthServer/.well-known/openid-configuration";
    options.ProviderOptions.ClientId = "test_client";
    options.ProviderOptions.ResponseType = "code";
    options.UserOptions.NameClaim = "sub";
    options.UserOptions.RoleClaim = "role";
    //options.UserOptions.NameClaim = ClaimTypes.Name;
    //options.UserOptions.RoleClaim = ClaimTypes.Role;
    options.UserOptions.ScopeClaim = "scope";
    
    // Clientで署名のチェックを行う場合は、CustomUserFactoryのIAccessTokenProviderで行う
}).AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, CustomUserAccount, CustomUserFactory>();

builder.Services.AddLogging();

builder.Services.AddApiAuthorization();

await builder.Build().RunAsync();

CustomUserAccount.cs

access_tokenに格納されているnameを格納するクラス。色々やってみましたが、subがなぜか格納されません。

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using System.Text.Json.Serialization;

namespace TestWebAssembly.Client.Models
{
    public class CustomUserAccount : RemoteUserAccount
    {
        [JsonPropertyName("name")]
        public string[]? Name { get; set; }
    }
}

CustomUserFactory.cs

認証情報にロールを追加します。
ロールは本当はハードコーティングではなく、AsyncFromJsonなどでWebAPIサーバから取得します。

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using System.Runtime.CompilerServices;
using System.Security.Claims;

namespace TestWebAssembly.Client.Models
{
    public class CustomUserFactory : AccountClaimsPrincipalFactory<CustomUserAccount>
    {
        private IAccessTokenProviderAccessor _accessor;
        private IServiceProvider _serviceProvider;
        
        public CustomUserFactory(IAccessTokenProviderAccessor accessor
            , IServiceProvider serviceProvider)
            : base(accessor)
        {
            _accessor = accessor;
            _serviceProvider = serviceProvider;

        }

        public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
            CustomUserAccount account, RemoteAuthenticationUserOptions options)
        {
            var initialUser = await base.CreateUserAsync(account, options);
            if (initialUser == null || initialUser.Identity == null)
            {
                throw new ApplicationException("ロールの取得に失敗しました");
            }
            if(initialUser.Identity.IsAuthenticated)
            {
                // ロールの取得
                var userIdentity = (ClaimsIdentity)initialUser.Identity;
                HttpClient client = _serviceProvider.GetService<HttpClient>();
                // 本当はGetFromJsonAsyncなどでサーバからロールを取得する
                userIdentity.AddClaim(new Claim(options.RoleClaim, "Administrator"));
            }
            return initialUser;
        }
    }
}

Counter.razor

権限設定し、「Administrator2」ロールが付与されていないと参照不可にします。

@page "/counter"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]

<PageTitle>Counter</PageTitle>

<AuthorizeView Roles="Administrator2">
    <Authorized>

        <h1>Counter</h1>

        <p role="status">Current count: @currentCount</p>

        <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

    </Authorized>
    <NotAuthorized>
        Role NG
    </NotAuthorized>
</AuthorizeView>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

FetchData.razor

権限設定し、「Administrator」ロールが付与されていないと参照不可にします。

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

<PageTitle>Weather forecast</PageTitle>

<h1>Weather forecast</h1>

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

<AuthorizeView Roles="Administrator">
    <Authorized>
        @if (forecasts == null)
        {
            <p><em>Loading...</em></p>
        }
        else
        {
            <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>
        }

    </Authorized>
    <NotAuthorized>
        Role NG
    </NotAuthorized>
</AuthorizeView>

@code {
    private WeatherForecast[]? forecasts;

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

Webサーバ設定

Server/Program.csにAddJwtBearerを追加し、Web Client(Blazor WebAssembly)からのリクエストのaccess_tokenヘッダの検証を行います。検証の結果、不適切な場合は認証エラーを返却します。
検証の結果、正常であればロール「Administrator」を取得します。
ここで取得したロールはClientに返却することはせず、WebサーバのWebAPIの権限判定のために使用します。
なお、プログラムではロール「Administrator」はハードコーディングですが、本来はaccess_tokenの内容に応じてDB等からユーザに紐づくロールを取得します。

WebAPI「WeatherForecastController」に権限設定を行い、「Administrator」ロールが付与されていないとアクセス不可にします。

Server/Program.cs

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using TestWebAssembly.Server.Data;
using TestWebAssembly.Server.Models;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Security.Cryptography;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

// HttpContextAccessorを追加する
builder.Services.AddHttpContextAccessor();

// 本来は設定ファイルから取得するが
// テストサーバの用途的に、固定で書くことにする
// 発行者
string oidcIssure = "oidc_server";

// Tokenを発行する対象のクライアント
string oidcClientId = "test_client";



// JwtBearerの設定
// クライアントから送られたトークンをチェックする処理
// チェックの過程でサーバからもOidcサーバにアクセスし、
// Jwksからキー情報を取得する
builder.Services.AddAuthentication(auth =>
{
    auth.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opt =>
{
    // OidcサーバへのURL
    // Web APIサーバとOIDCサーバを同じプロジェクトで作っているので、自分自身のURLを指定する
    // ただし、Dockerコンテナ内で動作される場合でかつ間にリバースプロキシが入る場合は下記の記述ではNG
    // JwtBearerはAjaxでOIDCサーバと通信するため、リバースプロキシ前のURLだと名前解決できない場合がある
    // そのため、原則localhostを使用してアクセスする
    // もう1つ制約があって、httpsからhttpsで同一サーバにアクセスするとエラーになってしまう。
    // そこで、http://localhostでアクセスする(kestrel起動時にhttpsとhttpの2つを起動しておく)
    // とりあえずここではVisual Studioのデバッガで動作させる前提なので、上記のことは考慮しない
    var httpContextAccessor = builder?.Services.BuildServiceProvider()
        .GetService<IHttpContextAccessor>();
    var context = httpContextAccessor.HttpContext;

    string baseUrl = context.Request.Scheme + "://" 
        + context.Request.Host.Value + context.Request.PathBase;
    if (baseUrl.EndsWith("/"))
    {
        baseUrl = baseUrl.TrimEnd('/');
    }
    // Default true
    //opt.TokenValidationParameters.ValidateAudience = true;
    //opt.TokenValidationParameters.ValidateIssuer = true;
    //opt.TokenValidationParameters.ValidateLifetime = true;

    // 使われた鍵のキーがWebサーバの鍵一覧にあること(Jwksを使うのでfalse)
    // Default false
    //opt.TokenValidationParameters.ValidateIssuerSigningKey = false;


    opt.RequireHttpsMetadata = false;
    opt.Audience = oidcClientId;
    opt.ClaimsIssuer = oidcIssure;

    opt.MetadataAddress = $"{baseUrl}/AuthServer/MetaDataByWebServer";
    opt.Authority = $"{baseUrl}/AuthServer";
    opt.Events = new JwtBearerEvents();

    opt.Events.OnTokenValidated = context =>
    {
        ClaimsIdentity clmIdentity = new ClaimsIdentity(
            "AuthenticationTypes.Federation");

        // ユーザIDをもとにRoleを(DB等から)取得する
        // 今回はテストなので割愛する

        string role = "Administrator";
        clmIdentity.AddClaim(new Claim(ClaimTypes.Role, role));

        // ロールを追加する
        context.Principal.AddIdentity(clmIdentity);

        return Task.CompletedTask;
    };
    //RSA rsa = RSA.Create();
    //rsa.ImportSubjectPublicKeyInfo(
    //    source: Convert.FromBase64String("xxxxxxxx")
    //    , bytesRead: out int _);
    //RsaSecurityKey publicKey = new RsaSecurityKey(rsa);

    //opt.TokenValidationParameters = new TokenValidationParameters
    //{

    //};
});

builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();

var app = builder.Build();

app.Use(async (context, next) =>
{
    //Console.WriteLine("Request " + context.Request.Path);
    await next();
    if (!context.Request.Path.Value.StartsWith("/_framework") 
        && !context.Request.Path.Value.StartsWith("/css"))
    {
        Console.WriteLine("Response " + context.Response.StatusCode
            + " " + context.Request.Path);
    }

});

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
    app.UseWebAssemblyDebugging();
}
else
{
    // WebAPIなのでエラー画面を出すのはNG
    // 本来はWebAPIのJsonでエラーコードを返却する。
    app.UseExceptionHandler("/Error");
    // The default HSTS value is 30 days. You may want to change 
    // this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();

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


app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");

app.Run();

WeatherForecastController.cs

Administratorの権限がないと呼び出し不可にします。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TestWebAssembly.Shared;

namespace TestWebAssembly.Server.Controllers
{
    // 複数ロールを許可する場合はカンマ区切り
    [Authorize(Roles = "Administrator")]
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
        private static readonly string[] Summaries = new[]
        {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

        private readonly ILogger<WeatherForecastController> _logger;

        public WeatherForecastController(ILogger<WeatherForecastController> logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public IEnumerable<WeatherForecast> Get()
        {
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }
}