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

.NET の OAuth クライアントは IdentityModel が定番だけど、あえて IdentityModel を使わず、HttpClient だけで OAuth2.0 の認可コードフローを通す。PKCE にも対応.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

namespace SampleWebApiClient
{
    class Program
    {
        const string IdentityServerAddress = "IdentityServerのアドレス";
        const string WebApiAddress = "Web API のアドレス";
        const string ClientId = "クライアント ID";
        const string Scope = "API スコープ";
        const string RedirectUri = "リダイレクト URI";

        static async Task Main(string[] args)
        {
            // PKCE(認証コード横取り対策)
            string codeVerifier;
            using (var rng = RNGCryptoServiceProvider.Create())
            {
                var bytes = new byte[32];
                rng.GetBytes(bytes);
                codeVerifier = Convert.ToBase64String(bytes)
                    .Split('=')[0]
                    .Replace('+', '-')
                    .Replace('/', '_');
            }
            string challenge;
            using (var sha256 = SHA256.Create())
            {
                var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
                challenge = Convert.ToBase64String(challengeBytes)
                    .Split('=')[0]
                    .Replace('+', '-')
                    .Replace('/', '_');
            }

            // Web ブラウザで認証ページを表示
            var authorizeUrl =
                $"{IdentityServerAddress}/connect/authorize" +
                "?client_id=" + ClientId +
                "&response_type=code" +
                "&scope=" + Uri.EscapeUriString(Scope) +
                "&redirect_uri=" + Uri.EscapeUriString(RedirectUri) +
                "&code_challenge=" + challenge +
                "&code_challenge_method=S256"; ;
            var escapedUrl = authorizeUrl.Replace("&", "^&");
            Process.Start(new ProcessStartInfo("cmd", $"/c start {escapedUrl}")
            {
                CreateNoWindow = true,
            });

            // 認証コードを使ってアクセストークンを取得
            Console.Write("AuthorizationCode:");
            var code = Console.ReadLine();
            var tokenClient = new HttpClient
            {
                BaseAddress = new Uri(IdentityServerAddress),
            };
            var tokenResponse = await tokenClient.PostAsync(
                "/connect/token",
                new FormUrlEncodedContent(new Dictionary<string, string>
                {
                    ["grant_type"] = "authorization_code",
                    ["code"] = code,
                    ["client_id"] = ClientId,
                    ["redirect_uri"] = RedirectUri,
                    ["code_verifier"] = codeVerifier,
                }));
            var tokenResponseBody = await tokenResponse.Content.ReadAsStringAsync();
            if (!tokenResponse.IsSuccessStatusCode)
            {
                Console.WriteLine(tokenResponseBody);
                goto END;
            }
            var accessToken = JsonDocument.Parse(tokenResponseBody)
                .RootElement
                .GetProperty("access_token")
                .GetString();

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

            // アクセストークンを設定
            apiClient.DefaultRequestHeaders.Authorization =
                new AuthenticationHeaderValue("Bearer", accessToken);

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

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