IdentityModel を使ってクライアントを OAuth 2.0 の認可コードフローと PKCE に対応させる

PKCE 対応に苦労したので、サンプルコードをメモしておく。

using System;
using System.Diagnostics;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using IdentityModel;
using IdentityModel.Client;

namespace SampleApiClient
{
    class Program
    {
        const string IdentityServerAddress = "IdentityServerのアドレス";
        const string WebApiAddress = "Web API のアドレス";
        const string ClientId = "クライアント ID";
        const string ClientSecret = "クライアントシークレット";
        const string Scope = "API スコープ";

        // 認証に成功した後リダイレクトする URL
        const string RedirectUrl = "http://localhost";

        static async Task Main(string[] args)
        {
            var tokenClient = new HttpClient
            {
                BaseAddress = new Uri(IdentityServerAddress),
            };

            var disco = await tokenClient.GetDiscoveryDocumentAsync();
            if (disco.IsError)
            {
                Console.WriteLine(disco.Error);
                return;
            }

            // PKCE 対応
            var codeVerifier = CryptoRandom.CreateUniqueId();
            string challenge;
            using (var sha256 = SHA256.Create())
            {
                var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
                challenge = Base64Url.Encode(challengeBytes);
            }

            // Web ブラウザでログインページを表示
            // ログインに成功したあと、クライアントへの Web API 利用許可も行ったら、
            // http://localhost にリダイレクトする。
            // Web ブラウザのアドレスバーの URL から認証コードを取り出せる。
            var nonce = CryptoRandom.CreateUniqueId();
            var request = new RequestUrl(disco.AuthorizeEndpoint);
            var authorizeUrl = request.CreateAuthorizeUrl(
                clientId: ClientId,
                responseType: OidcConstants.ResponseTypes.Code,
                scope: Scope,
                redirectUri: RedirectUrl,
                nonce: nonce,
                codeChallenge: challenge,
                codeChallengeMethod: OidcConstants.CodeChallengeMethods.Sha256);
            var escapedUrl = authorizeUrl.Replace("&", "^&");
            Process.Start(new ProcessStartInfo("cmd", $"/c start {escapedUrl}")
            {
                CreateNoWindow = true,
            });

            // 認証コードを使ってアクセストークンを取得
            Console.Write("AuthorizationCode:");
            var code = Console.ReadLine();
            var codeRequest = new AuthorizationCodeTokenRequest
            {
                Address = disco.TokenEndpoint,

                ClientId = ClientId,
                ClientSecret = ClientSecret,
                Code = code,
                RedirectUri = RedirectUrl,
                CodeVerifier = codeVerifier,
            };
            var tokenResponse = await tokenClient.RequestAuthorizationCodeTokenAsync(codeRequest);
            if (tokenResponse.IsError)
            {
                Console.WriteLine(tokenResponse.Error);
                return;
            }

            // Web API を呼び出すクライアントは、
            // アクセストークンを取得するクライアントとは別
            var apiClient = new HttpClient
            {
                BaseAddress = new Uri(WebApiAddress),
            };

            // アクセストークンを設定
            apiClient.SetBearerToken(tokenResponse.AccessToken);

            // データを取得
            var response = await apiClient.GetAsync(
                "/api/products");
            response.EnsureSuccessStatusCode();
            Console.WriteLine(await response.Content.ReadAsStringAsync());

            Console.WriteLine("Enter で終了");
            Console.ReadLine();
        }
    }
}