しばやん雑記

Azure Serverless とメイドさんが大好きなフリーランスのプログラマーのブログ

WPF と WebView2 で MSAL を使わずに Azure AD 向け認可コードフローを実装してみた

普段は App Service の Easy Auth を使ってサクッと済ませるのですが、ちょいちょい MSAL を使った Azure AD や Azure AD B2C へのログイン処理をアプリケーションに組み込むことがあります。

特にネイティブアプリケーションの場合が多いので、.NET Core の WPF でサクッと書こうとするのですが、MSAL.NET は .NET Core の場合は利用が若干面倒です。具体的にはシステムのブラウザを使う必要があるので、リダイレクト先として localhost を準備しないといけないようです。

ちなみに .NET Framework の WPF では WebBrowser コントロールが使われたポップアップ内でログインが行えるので、通常使っているブラウザでのセッションを共有は出来ませんが、簡単に実装が出来ます。

MSAL 系のドキュメントは若干分かりにくいものが多いですが、チュートリアルは用意されています。

.NET Core への対応がイマイチなので .NET Framework の WPF を使っても WebBrowser は IE11 相当のエンジンが使われるので、Google など外部の IdP を追加すると以下のように動作が怪しくなります。

ログイン画面自体も古いバージョンになっていたので、古いブラウザ用のページに飛ばされているようです。

f:id:shiba-yan:20201127141535p:plain

最近 Chromium Edge ベースの WebView2 がリリースされたので、この辺りの問題は解消されるでしょう。

しかし直近ではどうしようもなくなってしまうので、あえて MSAL を使わずに .NET Core の WPF と WebView2 を使って、自前でログイン処理を実装してみることにしました。

昔なら Implicit Flow でお茶を濁していたところですが、最近は Authorization Code Flow + PKCE が常識になっているので、こっちのフローを実装していきます。

認可コードフローの実装

WPF と WebView2 の導入に関しては今回は本質的な部分ではないのでまるっと省略します。適当に .NET Core な WPF プロジェクトを作成して、WebView2 をインストールするだけです。

認可コードフローについては Azure AD のドキュメントに説明があるので参考にしてください。そんなに難しくないのですぐに理解できると思います。

今回は Azure AD B2C を使うのでエンドポイントが異なりますが、フロー自体は同じなので違いは僅かです。

Azure AD へのアプリケーション登録時に Mobile and Desktop Client を選択してリダイレクト URI を追加しますが、今回は MSAL only となっている方を使ってみます。結局のところリダイレクトされた URL を WebView のイベントで取るので、判別できるものなら何でも良いです。

f:id:shiba-yan:20201127020536p:plain

Win32 のデスクトップアプリケーションではリダイレクト先を用意できないので、WebView + 特殊なリダイレクト先を使っているという流れです。UWP や iOS / Android アプリケーションではリダイレクト先を用意できるので、システムブラウザからリダイレクトして戻ってくることも簡単に実装可能です。

早速実装に入っていきますが、最初からコードを全て載せてしまうことにします。

やっていることは単純で authorize エンドポイントへの URL を組み立てて、WebView を使ってアプリ内からアクセスしつつ、ナビゲーションイベントで特殊なリダイレクト先の場合は token エンドポイントを叩いて id_token を貰っているだけです。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    private const string TenantBase = "https://***.b2clogin.com/***.onmicrosoft.com";
    private const string ClientId = "00000000-0000-0000-0000-000000000000";
    private const string PolicyId = "B2C_1_SignUp_SignIn_v2";
    private const string RedirectUri = "msal00000000-0000-0000-0000-000000000000://auth";

    private readonly HttpClient _httpClient = new HttpClient();

    private void Window_Loaded(object sender, RoutedEventArgs e)
    {
        var authorizeUri = new StringBuilder();

        authorizeUri.Append(TenantBase)
                    .Append("/oauth2/v2.0/authorize")
                    .Append($"?p={PolicyId}")
                    .Append($"&client_id={ClientId}")
                    .Append($"&nonce=defaultNonce")
                    .Append($"&redirect_uri={WebUtility.UrlEncode(RedirectUri)}")
                    .Append($"&scope=openid")
                    .Append($"&response_type=code")
                    .Append($"&prompt=login");

        webView2.Source = new Uri(authorizeUri.ToString());
    }

    private async void WebView2_NavigationStarting(object sender, CoreWebView2NavigationStartingEventArgs e)
    {
        if (!e.Uri.StartsWith(RedirectUri))
        {
            return;
        }

        var code = ExtractAuthCode(e.Uri.Replace(RedirectUri, ""));

        var content = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            { "grant_type", "authorization_code" },
            { "code", code }
        });

        var tokenUri = new StringBuilder();

        tokenUri.Append(TenantBase)
                .Append("/oauth2/v2.0/token")
                .Append($"?p={PolicyId}");

        var response = await _httpClient.PostAsync(tokenUri.ToString(), content);

        var result = await response.Content.ReadAsStringAsync();

        MessageBox.Show(result);
    }

    private string ExtractAuthCode(string query)
    {
        return query.TrimStart('?').Replace("code=", "");
    }
}

一番長いのは URL を組み立てている部分なので、それを除くとコード量は大したことは無いですね。

WebView2 固有の挙動としては、表示するページ URL は Source プロパティで指定するのが安心というものがあります。良くある Navigate メソッドは CoreWebView2 に用意されていますが、遅延初期化されているようでタイミングによって null が返ってきます。

CoreWebView2Ready イベントを待ってから実行すれば良いですが、Source プロパティを使うとその辺りを気にしなくても良い感じに扱ってくれます。実行するとお馴染みのログイン画面が表示されます。

f:id:shiba-yan:20201127022128p:plain

メールアドレスとパスワードを入力してログインすると、認可コードフローが実行されて id_token が取得されました。リダイレクトのイベントによって処理を行っているのでぱっと見は良く分からないと思いますが、ブレークポイントを仕掛けると理解しやすいはずです。

f:id:shiba-yan:20201127022140p:plain

今回は WebView2 を使っているので、Google などの IdP を選んだ場合もちゃんとページが表示されます。

f:id:shiba-yan:20201127141553p:plain

認可コードフローの基本的な実装はこれで完成ですが、最近は認可コードの横取り対策を入れることが推奨されているので追加で実装していきます。

PKCE に対応させる

ネイティブアプリケーションの場合は Web アプリケーションとは異なり、カスタム URI スキームを定義して起動できるので、認可コードの横取りが簡単に行えます。

この辺りの理解には RFC を読んでおきました。元ソースに当たるのは重要ですね。

仕様としてはログインを開始したアプリケーションしか知らない値を都度ランダム生成し、それぞれのリクエストに渡すだけなので簡単です。

具体的には authorize エンドポイントには code_challenge code_challenge_method を追加して、token エンドポイントには code_verifier を渡します。

ちなみに code_verifier はランダムで生成した文字列、code_challenge は一般的には SHA-256 を計算した値です。以下のメソッドを見てもらった方が理解は早そうです。

private string GenerateCodeVerifier()
{
    var buffer = new byte[32];

    _random.NextBytes(buffer);

    return ToBase64Url(buffer);
}

private string ComputeCodeChallenge(string codeVerifier)
{
    var sha256 = SHA256.Create();

    var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(codeVerifier));

    return ToBase64Url(hash);
}

private string ToBase64Url(byte[] input)
{
    return Convert.ToBase64String(input)
                    .TrimEnd('=')
                    .Replace('+', '-')
                    .Replace('/', '_');
}

処理としてはログイン開始時に code_verifier を生成して、更に code_challenge を計算してクエリパラメータとして追加します。今回は SHA-256 を使うので code_challenge_method には S256 を設定します。

この時に code_verifier をセッション中は保持しておかないといけません。

private void Window_Loaded(object sender, RoutedEventArgs e)
{
    _codeVerifier = GenerateCodeVerifier();

    var codeChallenge = ComputeCodeChallenge(_codeVerifier);

    var authorizeUri = new StringBuilder();

    authorizeUri.Append(TenantBase)
                .Append("/oauth2/v2.0/authorize")
                .Append($"?p={PolicyId}")
                .Append($"&client_id={ClientId}")
                .Append($"&nonce=defaultNonce")
                .Append($"&redirect_uri={WebUtility.UrlEncode(RedirectUri)}")
                .Append($"&scope=openid")
                .Append($"&response_type=code")
                .Append($"&prompt=login")
                .Append($"&code_challenge={codeChallenge}")
                .Append($"&code_challenge_method=S256");

    webView2.Source = new Uri(authorizeUri.ToString());
}

ログインが完了し特殊なリダイレクト先に認可コード付きでリダイレクトされた後、token エンドポイントに code_verifier を追加で渡すようにします。

PKCE 付きで実行していた場合は省略できませんし、一致しない場合はエラーとなります。

private async void WebView2_NavigationStarting(object sender, CoreWebView2NavigationStartingEventArgs e)
{
    // 省略

    var content = new FormUrlEncodedContent(new Dictionary<string, string>
    {
        { "grant_type", "authorization_code" },
        { "code", code },
        { "code_verifier", _codeVerifier }
    });

    // 省略
}

これで認可コードフローと PKCE を自前で実装出来ました。思ったより簡単でした。

MSAL などのライブラリを使った方が楽ですが、WPF の WebBrowser 問題のようにどうしようもない時もあるのと、フローを知っておくのは良いことなので勉強になります。