BEACHSIDE BLOG

Azure と GitHub と C# が好きなエンジニアの個人メモ ( ・ㅂ・)و ̑̑

Graph API で Azure AD B2C のコンシューマーユーザーを作成する (C#)

C# で B2C のユーザーを登録する方法を書いていきます。C# の話というよりは、Azure AD B2C の設定がほとんどって感じです。

事前に知っておきたい知識

ユーザーアカウントの種類

Azure AD B2C ではユーザーアカウントが3種類あります。

  • 職場アカウント (Local user)
  • ゲストアカウント (Guest user)
  • コンシューマーアカウント (Azure AD B2C user)

なんかのアプリを B2C で認証してログインだけさせたい場合、コンシューマーアカウント を作成する必要があります。ほかの二つのユーザーアカウントは B2C テナントの管理用アカウントみたいなもので、アプリで認証を通すためには使えないアカウントになります。基本かつ大事な概念のはずですが、Azure ポータルの B2C テナントを「コンシューマーアカウント」って用語の表示無いのはふむーっと感じる日々です。

ということで今回はコンシューマーアカウントを Microsoft Graph の SDK で登録する際の B2C の設定やコードを書いていきます。

ちなみに SDK から B2C へアクセス (Graph API へアクセス) するのには、Client Credentials フロー (クライアント資格情報フロー) を使います。

ユーザーアカウントの違い

余談となりますがさっき出てきたユーザーアカウントごとの特徴をざっくりと書いておきます。細かい点を書くと長くなるのであくまでざっくりな内容です。

ユーザーアカウントの種類 B2C のユーザーフロー/Custom Policyでのログイン Azure Portal へのログイン 補足
職場アカウント (Local user) 可能 可能 ロールを付与すれば Azure Portal にサインインしてAAD や AAD B2C リソースの操作が可能。ただしユーザーのドメインの制約がある。
ゲストアカウント (Guest user) 不可 可能 単なる Azure のリソースがある Azure AD へゲストユーザー。ロールを付与すれば Azure Portal にサインインしてAAD や AAD B2C リソースの操作が可能。
コンシューマーアカウント (Azure AD B2C user) 可能 不可能 (ログインはできるが制限付きテナント扱い) このユーザーにglobal admin のロールを付与しても、Azure ポータルにログインしてリソースの操作はできない。B2C のユーザーフロー/Custom Policy で認証するためのユーザー。

B2C でサービスプリンシパルの作成

今回、Graph API への認証は Client Credentials フローでアクセスします。

それを実現するために B2C 側でサービスプリンシパルを作成します。

※ ちなみに B2C の操作は強い権限が必要です。ここでは Global Administrator で操作することを想定しますので、弱い権限のユーザーだと操作できないものもあります。

Application の作成

Azure ポータルで B2C のリソースを開き、App registration をクリック → New registration をクリックします。

そういえばブログ書くためにいつも Azure ポータルを日本語に変えてましたが...めんどいのとわけわからん日本語訳見るのがしんどいので英語のままで行きます。

App の作成で大事なのは、Supported account types を一番上のシングルテナントのみに設定することくらいです。あとは適当に名前つけて Permissions のチェックもオンのままで作成すれば OK です。

ここでは graph-api-app という名前で作成しました。

App の API Permissions の追加

作った App で Graph API で操作できる権限を増やします。今回はユーザーを作成する権限を追加します。

先ほど作成した App: graph-api-app が表示された状態になっているはずなので、**API Permissions をクリック→ Add a permission をクリックします。

Microsoft APIs で表示される Microsoft Graph をクリックします。

まず type は Application permissions を選びます。あとは、検索のところで user. ("."まで入れるといい感じに表示されます) と入力すると User セクションが表示されるのでその中の User.ReadWrite.All にチェックを入れて Add permissions ボタンをクリックします。

Add permissions ボタンをクリックすると権限をリクエストしただけの状態で付与はされていない状態になります (過去に付与したことがある場合、状態次第ではすぐに付与した状態にもなりますが) 。

以下図のように Status が Warning の状態になっていたら、Grant admin consent for xxxx のボタンをクリックします。これで権限の付与がされます。

※ ボタンが押せない場合は、global administrator の権限を持っている人にお願いしてください。

シークレットの作成

プログラムから Client credentials フローで Graph API を操作するには Secret が必要になりますので作ります。

App の左側のメニュー Certificates & secrets をクリック → New client secret をクリックします。

名前や有効期限の設定画面がでますので適当にクリックして作成します。作成後、画面に表示される Secret の Value をメモしておきます。これは後で再表示はできないので注意して扱います。

B2C の情報を取得

先述で取得した secret を含め以下4つの情報をコピーしておきます。

  • Secret
  • client ID
  • tenant ID
  • Domain name

client IDtenant ID は、app の overview から確認できます。

Domain name は、Azure AD B2C のリソースの overview から確認ができます。

これで B2C のユーザーを操作するための準備ができました。プログラムを書いていきましょう。

C# コンソールアプリでコンシューマーユーザーを登録する

Console でも Web API でも Function App でもどれもかわらんので、ざっくり Console で作ります。

まずは Visual Studio でコンソールアプリのプロジェクトを作ります。Framework はもちろん .NET 6.0 です。

NuGet package のインストール

NuGet の前に... 今回はシークレット情報を使うので、シークレットの格納場所であるユーザーシークレットに B2C の情報を置いておきます。ソリューションエクスプローラーでプロジェクトを右クリック → ユーザーシークレットの管理 をクリックします。

こんなウインドウがでるので「はい」をクリックします。これでユーザーシークレットの json が作られ secret.json が開かれた状態になります。また Microsoft.Extensions.Configuration.UserSecret も自動でインストールされます。

NuGet パッケージマネージャーを開きたいので、Ctrl + Q キーを押してカーソルを検索に移動し、「nuget」と入力して NuGet パッケージの管理を開きます。今回は単一のプロジェクトなので、プロジェクトのでもソリューションのでもどっちの「NuGet パッケージの管理」でもよいです。

まず、自動でインストールされた Microsoft.Extensions.Configuration.UserSecret はなぜかバージョンが古いので最新の 6 系のに更新します。

次はこれをインストールします。バージョンは最新のやつを選びます。

  • Microsoft.Extensions.Configuration.Binder

ついでに Graph API 関連の以下2つもインストールします。

  • Azure.Identity
  • Microsoft.Graph

インストールしたパッケージは、全て現時点での最新の安定版になります。

B2C の情報を構成する

ソリューションエクスプローラーでプロジェクトを右クリック → ユーザーシークレットの管理 をクリック してひらいた secret.json を開いて、以下のように構成します。また、先述でメモした値を貼り付けます。issuer は、Domain name をセットします。

{
  "b2cConfig": {
    "tenantId": "",
    "clientId": "",
    "secret": "",
    "issuer": ""
  }
}

シークレットを保持するクラスを作成

シークレットを保持する POCO と、シークレットを取得して POCO をインスタンス化するためのメソッドをこんな感じで作りました。

using Microsoft.Extensions.Configuration;

namespace ConsoleApp1;

public class B2CConfig
{
    public string TenantId { get; set; }
    public string ClientId { get; set; }
    public string Secret { get; set; }
    public string Issuer { get; set; }

    public static B2CConfig ReadFromUserSecrets()
    {
        var builder = new ConfigurationBuilder()
            .AddUserSecrets<Program>();

        return builder.Build().GetSection("b2cConfig").Get<B2CConfig>();
    }
}

Main() をいじっていく

まずは、おきまりの Main のシグネチャを static async Task Main() に変えて、あとはさっき作った B2C の情報を取得するメソッドを呼んでおきますか。

using Azure.Identity;
using Microsoft.Graph;

namespace ConsoleApp1;

internal class Program
{
    static async Task Main()
    {
        var b2cConfig = B2CConfig.ReadFromUserSecrets();
    }
}

次は、Graph API の client のインスタンスを初期化するメソッドを作って、Main() から呼ぶ感じでいきますか。

Graph API の client GraphServiceClientCreateGraphServiceClient() で書いてある通りです。これはほぼお決まりの書き方ですかね。

    static async Task Main(string[] args)
    {
        var b2cConfig = B2CConfig.ReadFromUserSecrets();
        var client = CreateGraphServiceClient(b2cConfig);
    }

    private static GraphServiceClient CreateGraphServiceClient(B2CConfig b2cConfig)
    {
        var scopes = new[] { "https://graph.microsoft.com/.default" };
        var options = new TokenCredentialOptions
        {
            AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
        };

        var credential = new ClientSecretCredential(b2cConfig.TenantId, b2cConfig.ClientId, b2cConfig.Secret, options);
        return new GraphServiceClient(credential, scopes);
    }
}

さて本題のユーザー登録です。大事なことなので重ねて書きますがユーザーアカウントの種類は コンシューマーアカウント です。今回は適当なドメインのメールアドレスをもつユーザーを登録する想定です。つまり、ゲストアカウント (Guest user, b2cbeachside2.onmicrosoft.com ドメインのアカウント) ではないメールアドレスです。

ちなみにユーザーには仮パスワードを設定して登録し、ユーザーが初回のサインイン時に強制的にパスワードのリセットを行う想定で作っています。

    private static async Task CreateConsumerUserAsync(GraphServiceClient client, string issuer)
    {
        // 適当なユーザー情報
        var email = "yokohama@beachside.dev";
        var displayName = "BEACHSIDE 01";
        var password = "xWwvJ]6NMw+bWH-d";

        var userToCreate = new User
        {
            AccountEnabled = true,
            DisplayName = displayName,
            Identities = new List<ObjectIdentity>
            {
                new ObjectIdentity
                {
                    SignInType = "emailAddress",
                    Issuer = issuer,
                    IssuerAssignedId = email
                }
            },
            PasswordProfile = new PasswordProfile
            {
                ForceChangePasswordNextSignIn = true,
                Password = password
            }
        };

        var response = await client.Users.Request().AddAsync(userToCreate);

        // Object ID
        Console.WriteLine(response.Id);
        // Display Name
        Console.WriteLine(response.DisplayName);
        // response.UserPrincipalName の値は Object ID + "@b2cbeachside2.onmicrosoft.com" が表示されるが、
        // Azure ポータルで UserPrincipalName を確認すると、プログラムで設定した IssuerAssignedId の値 (email) が正しく登録されている
        Console.WriteLine(response.UserPrincipalName);  

この CreateConsumerUserAsync() メソッドを Main() で呼ぶ。

    static async Task Main(string[] args)
    {
        var b2cConfig = B2CConfig.ReadFromUserSecrets();
        var client = CreateGraphServiceClient(b2cConfig);

        await CreateConsumerUserAsync(client, b2cConfig.Issuer);
    }

これを実行して Azure ポータルで User を確認すると、こんな感じで登録されていることが確認できます。

Sign-in して動作を確認

では B2C のサインインのユーザーフローでサインインして、初回にパスワードの強制リセットされるかを確認しましょう。

サインインを試すために、app とユーザーフローの作成します。

app の作成

Azure ポータルをひらき、B2C のリソースで App registrations から app を作成します。雑にはしょりますが jwt-test という名前で作成し赤枠の設定に注意するくらいです。

作成後 app の Authentication をクリックし、Implicit grant and hybrid flows セクションにあるチェックボックス2つを ON にします。

ユーザーフロー作成

Azure ポータルで B2C のリソースを開き、User flows をクリック → New user flow をクリックします。

今回はセルフサインアップは不要の想定なので、Sign in をクリック → Version セクションで Recommended を選んで Create ボタンをクリックします。

email でサインインする以外は基本的にデフォルトで問題ないですが、せっかくなので claim に DisplayName を含めるようにしました。名前は graph-user-sign-in にして Create ボタンをクリックします。

作成した graph-user-sign-in をクリックします。

Properties をクリックし、Password configuration セクションにある Forced password reset にチェックをいれ、上部の Save ボタンを忘れずにクリックします。この設定をし忘れると、[Password の強制リセット]が無効のためパスワードリセットのユーザーフローを経由しないとリセットができなくなります。

これで設定が完了です。

ユーザーフローの実行

Run user flow をクリックするとそのブレードが右側に表示されます。Application と Reply URL が先ほど作成した jwt-testhttps://jwt.ms になっていることを確認し、Run user flow をクリックします。ってか私は、endpoint の URL をコピーして、Chrome のシークレットウインドウなどで実行しますが。

サインインの画面がでますので、Graph API で作成したユーザーでサインインします。先述した Forced password reset の設定を忘れると「The password has expired.」とエラーが表示されてサインインのユーザーフローからはパスワード変更ができなくなります (先ほども書きましたが、パスワードリセットのユーザーフローを作ってリセットせなあかん状態になります) 。

サインインが正常にできると、パスワードのリセット画面が出るのでリセットします。

これで正常にログインして DisplayName もクレームに含んであることが確認できました。

まとめ

B2C の基本的なところを把握してないとドキュメントから読み取りにくいハマりポイントがあるのでサクッと書いておくかと思いましたが、B2C の設定が多いせいで長いブログになってしまいました。

最近非公開ブログばっかりでこっちはかけてなかったので、今月からはぼちぼち書くぞってモチベーションで行こうと思います。

参考

docs.microsoft.com