BEACHSIDE BLOG

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

プロンプトエンジニアリングの基礎: 1/3 (C# +Azure.OpenAI SDK)

DeepLearning.AIというサイトでは、アカウントを登録するだけで AI に関する学習コンテンツがあります。

その中の無償コンテンツのひとつで OpenAI + Python の「ChatGPT Prompt Engineering for Developers」を受講してみました。

そこで学んだことを Azure OpenAI + C# で Azure.OpenAI の SDK を使って実装しながら自分なりに振り返っていきます。

今回だけだと書ききれそうになかったので3つにわけました。順次公開したらリンクにしていきます。

  • プロンプトエンジニアリングの基礎: 1/3 (C# +Azure.OpenAI SDK) 👈 今ここ
    • プロンプトエンジニアリングにおける2大原則
  • プロンプトエンジニアリングの基礎: 2/3 (C# +Azure.OpenAI SDK)
    • プロンプトの育て方、文章要約、感情分析+α の Tips
  • プロンプトエンジニアリングの基礎: 3/3 (C# +Azure.OpenAI SDK)
    • 概要は後日更新予定

今回は、プロンプトエンジニアリングのガイドラインとなる2つの原則に対して実装例を書いていきます。

準備

.NET7 でコンソールアプリを作って、以下のNuGet パッケージを入れています。

  • Azure.AI.OpenAI: 1.0.0-beta.5

また VS の UserSecrets から Azure OpenAI の情報を取得しています。実装方法は前回のブログに書いているのでリンクをはっておきます。

blog.beachside.dev

Azure OpenAI の準備や細かい実装方法は↑のリンクに書いてますのでそちらをみてもらいつつ、あとはこんなコードで主に try-and-error region の中身を変えながらプロンプトエンジニアリングの Tips をまとめていきます。

using Azure;
using Azure.AI.OpenAI;

namespace Learning.PromptEngineering;

internal class Program
{
    static async Task Main()
    {
        var options = OpenAIOptions.ReadFromUserSecrets();
        var client = new OpenAIClient(new Uri(options.Endpoint), new AzureKeyCredential(options.ApiKey));

        #region try-and-error

        var prompt = """
            架空の映画のタイトルと監督・主演とジャンルのリストを3つ生成してください。
            回答はJSON形式で回答してください。
            """;

        #endregion

        var result = await GetChatCompletionAsync(client, options, prompt);
        Console.WriteLine(result);
    }

    private static async Task<string> GetChatCompletionAsync(OpenAIClient client, OpenAIOptions options, string prompt)
    {
        var chatCompletionsOptions = new ChatCompletionsOptions
        {
            MaxTokens = 1000,
            Messages =
            {
                new ChatMessage(ChatRole.User, prompt)
            }
        };

        var response = await client.GetChatCompletionsAsync(options.DeploymentName, chatCompletionsOptions);
        return response.Value.Choices[0].Message.Content;
    }
}

public class OpenAIOptions
{
    public string Endpoint { get; set; }
    public string ApiKey { get; set; }
    public string DeploymentName { get; set; }

    public static OpenAIOptions ReadFromUserSecrets()
    {
        var builder = new ConfigurationBuilder()
            .AddUserSecrets<Program>();
        return builder.Build().GetSection(nameof(OpenAIOptions)).Get<OpenAIOptions>();
    }
}

では、ここからは本題としてプロンプトエンジニアリングをする上での2つの原則とその原則を実践するために使う Tips を書いていきます。

原則1: 明確で具体的な指示を出す

「明確」は「 短く」って意味ではなく、むしろ長くて具体的な指示である方がよい (長ければいいって話しではないけど)。

明確で具体的な指示をだすために使ういくつかの Tips をあげます。

1-1: 区切り文字を使う

区切り文字として使う代表的なのは、"```" や "---" や "###" や html のタグなど。この実践例はこのあといくつかでてきます。

1-2: 構造化された出力を指示する

箇条書きの文章なのか、HTML や JSON といった出力形式などを名確認指定するのがよいです。JSON などは、出力するプロパティするのがよいです。

ということで試してみましょう。まずはこんなプロンプトにしてみました。

        var prompt = """
            架空の映画のタイトルと監督・主演とジャンルのリストを3つ生成してください。
            回答はJSON形式で回答してください。
            """;

結果はこんな感じ。それっぽいけど後続で API たたくようなことがあるなら JSON がちょっといまいちですね。そしてなんで英語だよってなりますね。

プロンプトを改善してみましょう。

        var prompt = """
            架空の映画のタイトルと監督・主演とジャンルのリストを3つ生成してください。
            日本語で回答してください。
            回答は、JSON形式でJSON フォーマットのキーは以下を使います。
         
            title, director, mainActor, genre
            """;

結果は...うーんそれっぽいけど、JSON はそのまま API の body に入れるにはしんどい。

もうひとがんばりしてみます。回答例の区切り文字にはタグを使ってみました。区切り文字に "```" を使いがちな私ですが、今回色々試してると、タグを使うのが LLM が一番理解してくれやすいのかなぁと感じました。

        var prompt = """
            あなたのタスクは、架空の映画のタイトルとその監督、主演とジャンルを作ることです。
            日本語で回答します。
            3つの映画の回答を生成します。
           
            回答は、"回答例" のタグを参考にJSONフォーマットの配列で出力してください。

            <回答例>
            [
             { "title": "1つめの映画のタイトル", "director": "1つめの映画の監督" "actor", "genre":"1つめの映画のジャンル" },
             { "title": "2つめの映画のタイトル", "director": "2つめの映画の監督" "actor", "genre":"2つめの映画のジャンル" },
             { "title": "3つめの映画のタイトル", "director": "3つめの映画の監督" "actor", "genre":"3つめの映画のジャンル" }
            ]
            </回答例>
            """;

これくらいまで書くと (私が勝手に想定していた) 回答のフォーマットにたどり着きました。ここらへんは想定通りのフォーマットになるように試行錯誤が必要だなと感じます。

JSON のフォーマットは具体的に書かないと結構事故る印象があるので注意だなぁと感じる今日この頃です。

1-3: 条件が満たされているかを確認する

今回は、なんらかの手順が書かれたテキストを箇条書きにする、手順が書かれていないテキストに対しては回答しないという条件を定義しました。出力形式も箇条書きっぽい感じに指定しています。

text って変数を使ってますが、実際チャットするときはこれがユーザーからの INPUT になるイメージです。

        var text = """
            おいしい抹茶のいれかたは、まずふるった抹茶を茶杓2杯分( 約2グラム )茶碗に入れます。
            次に、70から80度に冷ましたお湯を70ミリリットル準備し、そこから少量を茶碗に注ぎ、ダマができないように、茶せんで溶くようにして混ぜます。
            残りのお湯を注いで、手首を前後に動かして最初は小刻みに、次は表面を整えるようにして混ぜます。大きな泡ができたら茶筅の先でつぶし、表面を整えるときれいに見えます。
            泡が細かいほど口当たりがなめらかでおいしく仕上がります。これで完成です。おいしいお菓子と一緒にゆっくり味わいましょう。
            """;

        var prompt = $"""
            ### で区切られたテキストが提供されます。一連の手順が含まれている場合、その手順を次の形式で書き直します。

            Step 1: ...
            Step 2: ...
            ...
            Step N: ...

            もし、テキストに手順がふくまれていない場合は、「手順は含まれていません。」と回答します。

            ### {text} ###
            """;

出力はこんな感じ。

テキストに手順が含まれないのを入れると想定通りに動作もします。

1-4: Few-shot プロンプトを使う

代表的なテクニックのひとつ、Few-shot プロンプトです。タスクの成功例を提示し、その後にタスクを依頼するってやつです。

多少だめそうな成功例を提示しても、ちゃんと答えてくれます。

        var question = "太陽系で一番大きい惑星は何ですか。";

        var prompt = $"""
            あなたのタスクは、一貫したスタイルで回答することです。

            <User>: 北海道は大きいですか
            <Assistant>: でっかいどー

            <User>: 太陽の大きさは
            <Assistant>: でっかいどー

            <User>: {question}
            """;

回答の最後の一行を補完する感じで回答してくれました。

ちなみに、prompt の最後に <Assistant>: とつけると...

            <User>: {question}
            <Assistant>:
            """;

回答は <Assistant>: より後ろの回答をしてくれます。賢い。

原則2: Model に考える時間を与える

実行したらレスポンス遅いから気長に待つっていう意味ではないです。複数のステップを踏んで解決してほしいときは明示的に書いてあげるって感じでしょうか。

(理解が正しいのか我ながら疑念がありますが) では Tips を書いていきます。

2-1: タスクを完了するための指示を与える

今回は桃太郎を題材にしてみます。桃太郎のあらすじの要約は、こちらのサイトのあらすじ②を使ってみます。

テキストとプロンプトはこんな感じにしてみました。

        var text = """
            あるところにおじいさんとおばあさんが住んでいて、おじいさんは山に芝を刈りに行きおばあさんは川で洗濯をしに行っていると、川から大きな桃が流れてきます。早速大きな桃を食べるために持ち帰り中を割ってみると中から赤子が出てきたではありませんか。
            二人は桃から生まれた赤子に桃太郎と名付けます。桃太郎は急速に成長していき、ある時、とあるうわさを聞きつけます。なんでも、悪い鬼が人間から金銀財宝を巻き上げているという噂を。
            桃太郎は鬼の悪事が許せず、鬼を退治しに行くと育ててくれたお爺さんおばあさんに打ち明けました。するとおじいさんとおばあさんは、旅先で食べるようにと黍団子を手渡すのです。
            旅の道中犬に出会った桃太郎は、犬から黍団子が欲しいと打ち明けられ、その代わりに鬼退治で加勢してほしいと頼み犬は加勢に了承します。今度は猿に同じことを言われ猿も加勢に賛同し、最後にキジと出会い記事にも黍団子を私すべての黍団子を使い果たします。
            桃太郎は犬、猿、記事をお供に従えて鬼退治へと鬼が住む島に乗り込むのです。鬼ノ島に乗り込んだ桃太郎一行は鬼と対峙し、双方争いの中、桃太郎側に勝敗が傾くと鬼たちは降参の意向を示し始めますが果たしてどうなるのでしょうか。
            """;

        var prompt = $"""
            あなたのタスクは、次のアクションを実行することです。
            1. 以下にある ### で区切られた TEXT を100文字程度の1文に要約します。
            2. 要約した文章を英語に翻訳します。
            3. 登場人物の名前を出力します。
            4. JSON Object を出力します。JSONのキーには "jp_summary""names" を使います。

            それぞれの回答は、改行で区切ります。

            TEXT:
            ###
            {text}
            ###
            """;

回答はこんな感じで想定通りにでます。

出力のフォーマットをもう少し指定して試してみましょう。

        var prompt = $"""
            あなたのタスクは、次のアクションを実行することです。

            1. 以下にある ### で区切られた TEXT を100文字程度の1文に要約します。
            2. 要約した文章を英語に翻訳します。
            3. 登場人物の名前を出力します。
            4. JSON Object を出力します。JSONのキーには "jp_summary""names" を使います。

            回答は次の形式で出力します。

            概要: <要約>
            翻訳: <要約の翻訳>
            登場人物: <登場人物のリスト>
            テキスト: <要約するテキスト>

            TEXT:
            ###
            {text}
            ###
            """;

想定通りに出力されますね。

2-2: 結論を急ぐ前に独自の解決策を見つけるように指示する

計算問題に対して、生徒が正しい回答かを評価するプロンプトで試していきます。今回は生徒の回答を意図的に間違ってみます。

まずはこんな感じ。

        var prompt = """
            あなたのタスクは、問題に対して生徒の回答が正しいかを判断することです。

            タスクを完了するのに以下の手順で行ないます。

            - 最初に、あなたが正しい回答を作成してください。 
            - 次に、あなたの回答と生徒の回答を比較して、生徒の回答が正しいかを評価してください。

            自身で問題を解くまでは、生徒の回答が正しいか判断してはいけません。
            以下のフォーマットを使って返答してください。

            ## 問題

            <ここに問題が書かれます>

            ### 生徒の回答
           
            <ここに生徒の回答が書かれます>

            ## 評価

            ### 正しい回答

            <解決までの手順を step by step でここに書きます>
     
            ### 生徒の回答は正しいか

            <正解または不正解で回答>

            ## 問題
            
            太陽光発電の建設にあたり、費用は以下です。

            - 土地代は、30000/平方メートル
            - ソーラーパネルのコストは、40000/平方メートル
            - 年間の保守費用は基本料金として1000000円と、追加の保守費用として5000/平方メートルがかかります。
            
            1年間のX平方メートルの合計コストは何円になるでしょうか。
           
            ### 生徒の回答
            
            それぞれのコストは
            - 土地代: 30000X
            - ソーラーパネルのコスト: 40000X
            - 保守費用: 5000X
            
            したがって合計は 30000X + 40000X + 1000000 + 5000X = 72000X + 1000000

            ## 評価
            """;

結果は、LLM の計算がおかしい。何度か試すと LLM が正しい回答を返すけど生徒の評価が間違っていたりと結構不安定。。。。。生徒の回答を正しいのに変えても何度か評価しても不安定。これは単純に日本語の理解が苦手なだけっぽい。

「結論を急ぐ前に独自の解決策を見つけるように指示する」って本質からはちょっとそれるけど、以下のように英語に翻訳して正答を作るよう指示すると品質はかなり安定したので、それもそれでひとつの Tips ですね。

            タスクを完了するのに以下の手順で行ないます。

            - 最初に、問題を英語に翻訳し、翻訳した文章を使ってあなたが正しい回答を作成してください。 
            - 次に、あなたの回答と生徒の回答を比較して、生徒の回答が正しいかを評価してください。

この原則については、「自身で問題を解くまでは、生徒の回答が正しいか判断してはいけません。」って指示がキーになるような雰囲気を感じたけど日本語だといまいち効果は感じなかった。英語だとまた違うんだろうなぁとか思いつつ次に進みます。

2-3: Hallucinations (幻覚)

Hallucinations ... LLM は存在しないものもあたかもあるように回答します。3.5 から 4 になって Hallucinations もかなり減ったらしいですが、完全になくすことは難しいでしょう。

ということで、Model はうそも平気でいうから気を付けようねって話です。人間もよっぽど嘘や間違ったこというから LLM ばっか責めるなよって思ってしまう今日この頃ですが。

Hallucinations を軽減させるには、最初に関連情報を与えてから関連情報に基づいた質問に回答させるとかが基本になるみたいだけど、どうプロンプトを構成すべきかは考慮すべきことたくさんあるから注意しようくらいの話にしておきます。

次回へ続く

長くなったので、今日はこの辺で続編シリーズにします。

blog.beachside.dev

Azure OpenAI で C# のSDK を使うとき最初に知っておきたい入門知識

今回は C# で OpenAI の SDK で ChatCompletion API を使ってチャットのサンプルコードを書きつつ、SDK を使うとき最初に知っておきたいいくつかの Tips を書きます。今回はざっくり以下を環境で話を進めます。

  • Azure OpenAI でモデルは GPT-3.5 以降のモデル
  • ChatCompletion API
  • C# のコンソールアプリ (C#11)
  • Azure SDK ファミリーの NuGet パッケージ: Azure.AI.OpenAI


🚧 2023年6月時点の内容になります。基本的なとこはそんなに変わらんと思いますが SDK のアップデートは早いのでご注意ください 🚧


ほんとは OpenAI のプロンプトエンジニアリングの基本的な原則を C# のコードとともに書くつもりが、そこにたどり着くまでの文が長くなったのでこのタイトルに替えました (汗) 。原則シリーズは次回に書きます。

Azure SDK を利用するメリット

Azure の SDK を使うメリットであり Tips のひとつの紹介です。OpenAI の SDK だけでなく Blob や Cosmos DB など他の Azure のリソースを操作する際の SDK を含め、インスタンスの初期化や認証、リトライの仕様が統一されているので、他のリソースを使ったことがあるとわかりやすいのが良いです。

具体的な例を1つあげると、リトライ処理は Azure SDK の Core 部分で仕様が統一されておりデフォルトで組み込まれています。リトライが組み込まれてるなんて当たり前感しかないですが、初学者が SDK を初めて利用するときには気づかないですよね。

ちなみにリトライのデフォルト値は以下になっています。

  • MaxRetries: 3
  • Delay: 0.8秒
  • MaxDelaye: 1分
  • Mode: Exponential (RetryMode.Exponential)
  • NetworkTimeout: 100秒

より詳しい情報やカスタマイズ方法については以下を参照するとよいです。

ということで話を戻し Azure OpenAI の準備に進みます。

Azure OpenAI の準備

OpenAIClient を初期化するには、Azure OpenAI のエンドポイントと API key を使います。

認証については今回はわかりやすい API Key でやります。Azure では Managed Identity を使うことで .... つまり雑に説明すると Azure AD の機能で認証することで API Key の管理が不要になりキー漏洩のリスクがなくなるって話しなので Managed Identity 使うのがおすすめです。

が、ここでそれ書くと脱線しつつやり方書くのが面倒なので、サンプルコードで試すには気楽に API Key でやっていきます。

Azure OpenAI のリソース作成とモデルのデプロイ

Azure OepnAI のリソースが無い場合は、Azure ポータルで作ります。以下のドキュメントを参考に Azure ポータルからポチポチするだけです。後でモデルも使うので Azure OpenAI Studio でモデルのデプロイもしておきましょう。

今回は ChatCompletion API でチャットをするので、gpt-35-turbo のモデルを選んでおくとよいです。(ちなみに GPT-4 は使うには申請必要)

方法 - Azure OpenAI Service を使用してリソースを作成し、モデルをデプロイする - Azure OpenAI | Microsoft Learn

エンドポイントと API Key、デプロイしたモデルのデプロイ名の取得

Azure ポータルで OpenAI のリソースを開く > "Key and Endpoint" を開くとエンドポイントと API Key が取得できますのでメモしておきます。

モデル名は、Azure OpenAI Studio を開き、"デプロイ" からデプロイしたモデルのデプロイ名 (DeploymentName) をメモしておきます。

コンソールアプリでチャットを試す

コンソールアプリの作成

プロンプトを構成する際に C# の生文字リテラルの機能を使いたいので、C#11 を使います。ということで .NET7 SDK は必要です。ちなみに Console App や Azure Fuction App 自体のバージョンは .NET6 でも LangVersion11 にすれば大丈夫です。

ということでまずはコンソールアプリを作っていきます。VS 2022 を起動して "新しいプロジェクトの作成" をクリックします。

コンソールアプリを選び "次へ" をクリックします。

プロジェクト名とか場所とか適当に入れて次へ進みます。

フレームワークは .NET6 でも .NET7でも OK ですが、.NET6 を選んだときに C#11を使う方法を書くので私は .NET6で行きます。

プロジェクトができたら生文字リテラル書いてみましょうか。Program.cs でコードをこんな感じにしてみました。

var text = """
    Hello
    "Raw string literal text"
    Foooo!
    """;

Console.WriteLine(text);

コンソールアプリさんが怒ります。

C#11にあげましょう。プロジェクト名をクリックしてプロジェクトファイルを開きます。 以下図のように <LangVersion>11</LangVersion> を追加して保存します。

これで生文字リテラルが利用できるようになったので、正常に実行できるようになります。ゆーてサンプルコード書くくらいなら生文字リテラル使わないことの方が多そうなんですがね...。

NuGet パッケージのインストール

Visual Studio 2022 で Ctrl + Q キーをおして検索にカーソルをあわせ「nuget」と入力してNuGet パッケージマネージャーの管理をクリックします (ソリューションでもプロジェクトのでもどっちでもいいです) 。

"参照" タブを開き「プレスリリースを含める」にチェックをいれて「Azure.AI.OpenAI」と入力してインストールします。このブログ書いた時点だと 1.0.0-beta.5 が最新でした。

あとは、以下の2つも今回使うので入れておきます。バージョンは、.NET6 を使ってるならv6の最新版、.NET7を使ってるなら v7の最新版を入れておきます。

  • Microsoft.Extensions.Configuration.Binder
  • Microsoft.Extensions.Configuration.UserSecrets

エンドポイントと API キーの管理

サンプルコードだし適当でいいとは思ってるんですが、GitHub にあげることを考えて UserSecrets の機能を使うことにしておきます。

ということで、ソリューションエクスプローラーでプロジェクト名を右クリック > "ユーザーシークレットの管理" をクリックします。

secret.json のファイルが開くので以下の感じにします。

  • endpoint, apiKey, deploymentName は、先述の "エンドポイントと API Key、デプロイしたモデルのデプロイ名の取得" セクションでメモした値を入力します。
  • 補足として json をキャメルケースで書いても C# でコード変えなくても普通に読み込んでくれるし Linux 環境でも大丈夫なはずですが、万が一のトラブルに備えてパスカルケースにしてます。
{
  "OpenAIOptions": {
    "Endpoint": "https://xxxxx.openai.azure.com/",
    "ApiKey": "xxxxxxxx",
    "DeploymentName": "xxxx"
  }
}

あとはこれを管理するコードを雑に書いておきます。ここでは OpenAIOptions ってクラスを追加して、コードは以下のようにしました。

using Microsoft.Extensions.Configuration;

namespace OpenAIDemo;

public class OpenAIOptions
{
    public string Endpoint { get; set; }
    public string ApiKey { get; set; }
    public string DeploymentName { get; set; }

    public static OpenAIOptions ReadFromUserSecrets()
    {
        var builder = new ConfigurationBuilder()
            .AddUserSecrets<Program>();
        return builder.Build().GetSection(nameof(OpenAIOptions)).Get<OpenAIOptions>();
    }
}

これで、Program.cs でこんなコードを書いてデバッグ実行すると情報が取得できては確認しましょう。

using OpenAIDemo;

var options = OpenAIOptions.ReadFromUserSecrets();

Console.WriteLine();

OpenAIClient の初期化

ようやく OepnAIClient に手をかけます。初期化は先述でも書いた通り Managed Identity は使わず今回はシンプルに API Key で初期化します。コードは簡単です。

using Azure.AI.OpenAI;
using Azure;
using OpenAIDemo;

var options = OpenAIOptions.ReadFromUserSecrets();

var client = new OpenAIClient(new Uri(options.Endpoint), new AzureKeyCredential(options.ApiKey));

チャットをしてみる

ChatCompletion API を使ってのチャットはこんな感じです。今回は、ユーザーメッセージをなげてアシスタントのメッセージを保持していってるので、私の名前を言ったら覚えてくれている感じになります。

using Azure.AI.OpenAI;
using Azure;
using OpenAIDemo;

var options = OpenAIOptions.ReadFromUserSecrets();
// OpenAIClient の初期化
var client = new OpenAIClient(new Uri(options.Endpoint), new AzureKeyCredential(options.ApiKey));

// システムメッセージをエンドポイントになげる
var chatCompletionsOptions = new ChatCompletionsOptions
{
    MaxTokens = 300,
    Messages =
    {
        new ChatMessage(ChatRole.System, """
                                あなたは Azure の専門家です。
                                250文字以内で初心者にやさしい感じで回答を返します。
                                """)
    }
};

await client.GetChatCompletionsAsync(options.DeploymentName, chatCompletionsOptions);

//ユーザーメッセージの送信
var userMessages = new[]
{
    "こんにちは、私はBEACHSIDEです。",
    "Azure Open AI とはなんですか。",
    "私の名前を覚えていますか。"
};

foreach (var userMessage in userMessages)
{
    // 送信するユーザーメッセージを chatCompletionsOptions に追加
    chatCompletionsOptions.Messages.Add(new ChatMessage(ChatRole.User, userMessage));
    Console.WriteLine($"{ChatRole.User}: {userMessage}");

    // ユーザーメッセージを送信し、アシスタントからのレスポンスを受け取る
    var response = await client.GetChatCompletionsAsync(options.DeploymentName, chatCompletionsOptions);

    foreach (var choice in response.Value.Choices)
    {
        Console.WriteLine($"{choice.Message.Role}: {choice.Message.Content}");
        // アシスタントからのメッセージを chatCompletionsOptions に追加
        chatCompletionsOptions.Messages.Add(new ChatMessage(choice.Message.Role, choice.Message.Content));
    }
}

結果はこんな感じになります。

Azure SDK を使う際に絶対忘れてはあかん1つのこと

Azure SDK 利用時に最初にしっておきたいことのひとつは以下のドキュメントに書いてあります。雑にまとめると Client を使う際は基本的にシングルトンで扱い、(サンプルのお試しではなく) 真面目にコード書くときは DI でライフサイクルの管理しましょうくらいな話ですが、興味があったら一読しましょう。

まとめ

今回は、C# で Azure の SDK を使って ChatCompletion API でチャットする初めの一歩の話をしました。

次回以降でプロンプトエンジニアリングの原則的なのを C# のコードをまじえてやっていこうと思います。

Azure CLI で B2C のアプリのシークレットを更新する

Azure CLI で Azure AD B2C や Azure AD の app registration で作成したアプリのクライアントシークレットの更新方法のメモです。

ここでは B2C の話になっていますが、Azure AD でも全く一緒です。

Azure CLI で Azure AD B2C (または Azure AD) の操作をするためにはテナントの切り替えとか手間があるので、それを書いておきたかっただけです。

そして出オチですが、実際に運用するなら CLI でクライアントシークレット更新を自動化するよりも Terraform 使って IaC で構成管理をしながら更新処理を自動化するのがおすすめと思っています。

テナント ID の確認

Azure CLI でなにかを操作するときは、操作したい対象の Subscrition / Tenant ID にログインする必要があります。
ということでまずは Azure Portal で Azure AD B2C のテナントの ID を確認していきます。

Azure Portal を開き、上部の "Directory and subscriptions" のアイコンをクリックして、B2C のテナントに切り替えます。今回だと "BEACHSIDE B2C 3nd" に切り替えます。

Azure AD B2C の Tenant ID と Primary Domain を確認します。B2C いえど Azure AD 上にあるので Azure AD のリソースを開き Overview から確認が可能です。

余談ですが、私の B2C のディレクトリは全て、上図のように Menu behavior を "Docked" にして、アイコンは Azure AD と Azure AD B2C + ちょっと位にしています。これくらいしか使わないので。

Azure CLI の操作

テナントの切り替え

ターミナル (私は PowerShell を使ってます) で az login コマンドでログインすると現在どの subscription/tenant にいるか確認できます。

もしくは az account list -o table でどのサブスクリプションのテナント ID に入っているか確認できます。下図はぼかしだらけで意味不明な感じはありますが IsDefault: True になっているのが現在のやつになります。

私の場合 Subsicription はあってるけどテナントが異なるので変更していきます。

Subscription だけを変えたいときはこちらのブログに書いたのでリンクをはっておきます。 Azure CLI で Azure に サインイン / テナント ( サブスクリプション ) 切り替え ( az account ...) - BEACHSIDE BLOG

本題のテナントを変えたいときは、テナントを指定してログインします。 先ほど Azure portal で確認した Primary domain が xxxxx.onmicrosoft.com の場合はこんなコマンドを打ちます。--allow-no-subscriptions は必要に応じて付けます。

az login --tenant xxxxx.onmicrosoft.com --allow-no-subscriptions

ログイン 出来たら、tenantId が表示されるので間違っていないかは確認しておきましょう。

クライアントシークレットの更新をする

さて、一応本題です。

App registration で登録している app 毎に、クライアントシークレットの一覧を見たり、更新削除ができます。
まずは Azure portal で B2C のリソースを開く → "App registration" を開いて、見たい app の Application ID を確認します。

ではとりあえずこの app の secret の一覧を見るコマンドです。--id には上で確認した Application ID を指定します。

az ad app credential list --id xxxxx-xxxx-xxxx-xxx -o table

こんな感じで確認ができます。

次は、新規にクライアントシークレットを追加してみましょう。--id には先ほどと同じで Application ID を指定します。

az ad app credential reset --id  xxxxx-xxxx-xxxx-xxx --display-name "demo-secret2" --append

Azure portal で対象の app の "Certificates & secret" をみると、正しく追加されていることがわかります。

コマンドを使う際は、他のオプションもあるので公式ドキュメントで確認しておくとよいです。

az ad app credential | Microsoft Learn

ちなみにコマンドのオプション --append が無いと既存のシークレットを削除して (2つあったら2つとも削除される)新たに1つのシークレットを作る動作をします。

そうするとどこかでこのシークレットを使っている際はそのサービスが一時的に利用できなくなるので、ダウンタイム無しでキーをローリングでアップデートできるよう、2つのキーで管理できるようコマンドを間違えないよう注意が必要です (こんなリスキーな操作は手作業をせず自動化しましょうってところですね)。

終わりに

今回は Azure CLI での変更方法を書いてみました。app に関する他の設定ももちろん CLI で操作可能です。

learn.microsoft.com

冒頭でも書きましたが本格的な運用では、冒頭でも書きましたがTerraform を使って IaC で管理しながら、Secret の有効期限の管理を自動化するのが安心安全でおすすめです。

blog.shibayan.jp

Graph API で Azure AD B2C のユーザーを次回サインイン時にパスワードを強制変更させたいときの注意点 ( C# )

Azure AD B2C のユーザーが次回サインインするときにパスワードの変更を強制させるのを Graph API でセットしたいときの話です。

以下のドキュメントで、ドキュメントの先頭に必要な permission が書かれており、Permission type が Application の場合は User.ReadWrite.All があれば大丈夫とかかれており、リンク先である「Example 3: Update the passwordProfile of a user to reset their password」セクションを見ると、B2C でサービスプリンシパルを作って SDK とかで graph API をたたけばいいやって感じなので躓くポイントはなさそうです。

しかーし!ドキュメントをちゃんと見ないと設定が足りずエラーになるのでメモしておきました。

事前準備

ということでまずは B2C でサービスプリンシパルを作る必要があります。手順は以下の記事に書いたので今日はリンク張るだけにしておきます。app の API Permissions もブログ同様 User.ReadWrite.All で大丈夫と。

blog.beachside.dev

実装編

実装して実行してみる

B2C で app 作って必要な情報をとってきて (詳細は先述のブログのリンクに書いてます) 、以下のコードに必要な値をセットして実行してみましょう。

// TODO: Azure AD B2C の app の情報3つを入力
const string tenantId = "";
const string clientId = "";
const string clientSecret = "";

var scopes = new[] { "https://graph.microsoft.com/.default" };
var options = new TokenCredentialOptions
{
    AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};

var credential = new ClientSecretCredential(tenantId, clientId, clientSecret, options);
var client = new GraphServiceClient(credential, scopes);

// TODO: 変更したいユーザーの objectId をセット
const string targetUserId = "";

var user = new User
{
    PasswordProfile = new PasswordProfile
    {
        ForceChangePasswordNextSignIn = true
    }
};

await client.Users[targetUserId]
    .Request()
    .UpdateAsync(user);

これを実行すると以下のエラーがでます、特権が必要やと。自然と Oh my oh my God ... ♪って頭の中で NewJeans の曲が流れます。

Authorization_RequestDenied
Message: Insufficient privileges to complete the operation

エラーを改善

原因

原因の特定のため、もう一度冒頭ではったリンクのドキュメントを見てみましょう。

Update user - Microsoft Graph v1.0 | Microsoft Learn

「Request body」セクションの中でなんか書いてますね。PasswordProfile の変更は、application の場合、User.ReadWrite.All + User Administrator role が必要と。

In delegated access, the calling app must be assigned the Directory.AccessAsUser.All delegated permission on behalf of the signed-in user. In application-only access, the calling app must be assigned the User.ReadWrite.All application permission and at least the User Administrator Azure AD role.

ということでこれを設定していきます。

Permission を付与する

では User Administration を付与していましょう。まずは Azure portal で B2C のテナントに入ります。右上のテナント名を確認して間違っていないことを確認しましょう。違うテナントにいる場合は、下図の赤の〇のとこのアイコンをクリックして B2C のテナントに切り替えます。

B2C のテナントに入ったら、B2C のリソースを開き Roles and administrators を開きます (①) 。検索で「user admin」と入力すると (②) 、User Administrator がフィルターされるのでクリックします。

Add assignments をクリックして、app の名前か Application (Client) ID で検索して追加すれば OK です。追加後は数分かかることもあるようなので軽く休憩をするのが良いです。

実装して実行してみる (2回目)

後は先述のコードを実行すると、エラーなく完了します。

これで、ForceChangePasswordNextSignIntrue にしたユーザーでログインすると、 パスワードリセットの画面に飛ばされて、無事に期待通りの動作になったことが確認できます。

余談: user: changePassword には注意

今回のとは関係ないですがパスワードを API から操作する話で、user-changepassword はAPI Permission の Application は非対応で delegeted のみになるのでハマらないようにって感じですね。

Durable Functions で instanceId を確認したい (C# / Typescript / Python)

Durable Functions でたまに instanceId を取得したいときがあります。例えば Activity function で external Event の URL を作りたいときとか。私的にはあまり使わない Durable functions の Python ではどうだっけって気になったので、C# と Typescript も一緒にメモしておきます。

C#

まずは C# からです。

C# > Starter

以下のコードは Http の Starter のテンプレートのコードです。ここでは instanceId を自動生成させるか自分で作るかってところになります。

  • StartNewAsync メソッドの第二引数でパラメーターをセットしない場合は、instanceId が自動生成されます。
  • StartNewAsync メソッドの第二引数でパラメーターをセットした場合は、それが instanceId となります。外部で instanceId を生成してここでも使いたいときは使える感じです。
[FunctionName("Function1_HttpStart")]
public static async Task<HttpResponseMessage> HttpStart(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestMessage req,
    [DurableClient] IDurableOrchestrationClient starter,
    ILogger log)
{
    var instanceId = await starter.StartNewAsync("Function1");
    //  第二引数にセットするとその値が instanceId となる。
    // var instanceId = await starter.StartNewAsync("Function1", "なんらかの ID");

    log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

    return starter.CreateCheckStatusResponse(req, instanceId);
}

C# > Orchestrator

Orchestrator は IDurableOrchestrationContext のプロパティから取得が可能です。

public static async Task<List<string>> RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var instanceId = context.InstanceId;
// 以下省略

C# > Activity

以下はテンプレートからコードを生成した際の Activiy trigger のコードです。[ActivityTrigger] の直後に適当な型を定義することで、Orchestrator からのパラメーターを受け取ることができるコードになっています。

[FunctionName(nameof(SayHello))]
public static string SayHello([ActivityTrigger] string name, ILogger log)
{
    log.LogInformation($"Saying hello to {name}.");
    return $"Hello {name}!";
}

これをちょっと変えていきます。

InstanceIdIDurableActivityContext が管理しているので、以下のようコードを変更することで IDurableActivityContext を受け取ることができます。そうすると Orchestrator からの渡されたパラメーターは context.GetInput<>() で取得するようになります。

InstanceId の取得とは全く無関係ですが、DurableClient のバインド方法も書きました。ブログ冒頭で instanceId を使うのが「external Event の URL を生成したいとき」って書いたのですが、それには DurableClient を使うのでついでに書きました。

[FunctionName(nameof(SayHello2))]
public static string SayHello2(
    [ActivityTrigger] IDurableActivityContext context,
    [DurableClient] IDurableOrchestrationClient orchestrationClient,
    ILogger log)
{
    var instanceId = context.InstanceId;
    var name = context.GetInput<string>();

    return $"Hello {name}-{instanceId}!";
}

Javascript (Typescript)

TS > Starter

以下のコードは Http の Starter のテンプレートのコードです。ここでは instanceId を自動生成させるか自分で作るかってところになります。

  • startNew メソッドの第二引数でパラメーターをセットしない場合は、instanceId が自動生成されます。
  • startNew メソッドの第二引数でパラメーターをセットした場合は、それが instanceId となります。外部で instanceId を生成してここでも使いたいときは使える感じです。
const httpStart: AzureFunction = async function (context: Context, req: HttpRequest): Promise<any> {
    const client = df.getClient(context);
    
    const instanceId = await client.startNew(req.params.functionName, undefined, req.body);
    context.log(`Started orchestration with ID = '${instanceId}'.`);

    return client.createCheckStatusResponse(context.bindingData.req, instanceId);
};

export default httpStart;

C# とほぼ同じこと書きましたね...

TS > Orchestrator

以下のコードは orchestrator function のテンプレートのコードです。
instanceIdcontext の中でいくつか存在していますが、context.bindingData.instanceId でとるのがよいかなと。理由は Activity Function の context と一緒の方法で取得できるのでってだけですが。

const orchestrator = df.orchestrator(function* (context) {
    // instanceId の取得
    context.log(`instanceId (orchestrator): ${context.bindingData.instanceId}`);

    context.log(`Orchestrator: instanceId: ${context.bindingData.instanceId}`)
    context.log(`Orchestrator: instanceId: ${context.bindings.context.instanceId}`)
    context.log(`Orchestrator: instanceId: ${context.bindingData.context.instanceId}`)
    return "Hello";
});

export default orchestrator;

TS > Activity

以下のコードは Activity function のテンプレートのコードです。
instanceId は context.bindingData.instanceId で取得できます。

const activityFunction: AzureFunction = async function (context: Context): Promise<string> {
    const instanceId = context.bindingData.instanceId;
    return instanceId ;
};

export default activityFunction;

ちなみに Typescript で durableClient を使うには、orchestrationClient or DurableClient を bind して df.getClient(instanceId) みたいな感じでやります (面倒でここではかかなかった...)。

Python

py > Starter

starter での instanceId の話は基本的に他の言語と一緒なので内容が冗長なので省略します。

py > Orchestrator

Orchestrator では他の言語同様 context から取得します。変数名は instance_id です。

def orchestrator_function(context: df.DurableOrchestrationContext):

    logging.info(f"instanceId: {context.instance_id}")

py > Activity

Node 同様に Python でもどうにかできるかと思いましたが、シンプルに関数の引数で instanceId 渡すのがよいのかなという結果に。

import azure.durable_functions as df して試行錯誤しようとおもったけど、いい感じに出来そうにないので諦めました。

まとめ

Activity や Orchestrator へパラメーターとして渡せば楽に解決するのでそれでも悪くないのですが、無理やりじゃないお作法でのやりかたをメモしておきましたが、Python の Activity function はチーンでした。

そんなことはさておき Durable Functions 便利なので使いどころもたくさんですが、なんとなく概要がわかったから実装してみようって段階になったら、まずこのドキュメント読みましょう...っていう本題と関係ないオチで今回は締めくくります。

learn.microsoft.com

HttpTrigger の Azure Functions でパスやリクエストヘッダーの値を使って DI する (C#)

今回は C# の Http Trigger の Azure Functions の DI する際の Tips です。

例えば、こんなパス /api/costomers/abc123/order にアクセスする際、abc123 が顧客の ID でその値を使って DI したいときとかの話です。後述しますがなんでも DI した方がいいってこともなく、Factory パターンを使っていい感じにインスタンスを生成・管理するってケースもあるので設計には注意が必要ですが。

そんなことはさておきやってきましょう。

環境

今回の C# / Azure Functions のざっくりな環境は以下です。

  • VS 2022 (v17.4.x)
  • Azure Functions のプロジェクト関連:
    • Functions worker: .NET 6.0
    • Microsoft.NET.Sdk.Functions (NuGet) : v4.1.x

2023年1月時点では、.NET 7 Isolated はまだまだイマイチだし LTS でもないので、.NET 6.0 を使っています。

Http Request の情報を使って DI をする

さて本題です。

DI の準備

今回は、Visual Studio で DependencyInjectionWithHttpContextSample って名前の Function App を作りました。 で、Azure Functions で DI をするためのコードを追加するんですが、基本的には以下のドキュメントの通りなので詳しい設定方法省きます。

雑に書いておくと、ドキュメントにそって「サービスを登録する」セクションまでやれば OK です。Startup class の Configure メソッドの中身は空っぽで OK です。今回使ってる NuGet パッケージのバージョン書いておきます。

  • Microsoft.Azure.Functions.Extensions: v1.1.0
  • Microsoft.Extensions.DependencyInjection: v6.0.1

learn.microsoft.com

DI を構成する

まずはサンプルとして DI で取得したい class をこんな感じで定義しました。

namespace DependencyInjectionWithHttpContextSample;

public class SampleOptions
{
    public string Hobby { get; set; }
    public string CustomerId { get; set; }
    public string UserId { get; set; }
}

次は本題の Startup.cs です。

Http リクエストからの情報を取得するには、IHttpContextAccessor を使えば実現できます。

15行目で IHttpContextAccessor のインスタンスを取得することで、Http Request に関する情報が取得できます。今回は代表的なパターンとして以下3つを取得してみました。

  • Request header の情報 (18行目)
  • リクエストで来たパスの情報 (22-23行目)
  • query string の情報 (26行目)


※ 余談ですが、Options 的な class 書いたなら Options pattern 使おうよとかは、無駄にサンプルコードが増えるので今回は無しです。


DI を構成する際の注意点

DI を構成する際の注意点は色々ありますが、1つだけ書いておくとすればインスタンスの lifetime (またはライフサイクル) の設定を適切にしましょうってところでしょうか。

Function App (C#) での lifetime は、 Singleton or Scoped or Transient が構成できます。DB とか外部アクセスのクライアントの DI は lifetime を間違えることで重大なバグの温床になりかねないので、ユースケースや SDK の仕様にあった適切な設定をしましょう。

余談として、Singleton or Scoped or Transient の違いは、公式ドキュメントのこちら だとわかりずらいと感じた場合、以下のブログで具体的な動作の違いを解説したので参考になるかもです。すごーく昔に書いた ASP.NET に関する話ですが、Singleton or Scoped or Transient の概念はこの時から一緒です。

blog.beachside.dev

Function のコード

本題に戻りますが、あとは Function のコードでいい感じに呼び出せばいいだけです。今回は Constructor injectionで取得した SampleOptions class のインスタンスを返すだけのコードです。

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;

namespace DependencyInjectionWithHttpContextSample;

public class Function1
{
    private readonly SampleOptions _options;

    public Function1(SampleOptions options)
    {
        _options = options;
    }

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "customers/{customerId}/order")] HttpRequest req, ILogger log)
    {
        return new OkObjectResult(_options);
    }
}

リクエストを送信して動作確認

こんな感じで POSTMAN からリクエストを送信してみます。

そうすると、以下のようにレスポンスが返ってくるので想定通りの動作になっていることが確認できます。

(補足) DI よりも Factory pattern が扱いやすいケースもある

参考までに、場合によっては DI を使わず Factory pattern が扱いやすいこともあるでしょう。そんなときは以下のコードでが実現できます。

    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "customers/{customerId}/order")] HttpRequest req, ILogger log)
    {
        var options = SampleOptionsFactory.Create(req);
        return new OkObjectResult(options);
    }
    
    internal class SampleOptionsFactory
    {
        internal static SampleOptions Create(HttpRequest req)
        {
            return new SampleOptions
            {
                UserId = req.Headers["X-MS-CLIENT-PRINCIPAL-ID"],
                CustomerId = req.Path.ToString().Split('/')[3],
                Hobby = req.Query["hobby"]
            };
        }
    }

まとめ

用途に合わせていい感じに Function を活用しましょうってお話しでした。

1年以上前に作った自分のタスクを今更消化した...

Cosmos DB の Bulk Executor を使った一括インポート

ざっくりまとめ:

  • サポートしてる API は、Azure Cosmos DB SQL API と Gremlin API
  • 一括インポート API と一括更新 API
  • SDK v2 だと外部 SDK が必要だが、SDK v3 ではサポートされているので外部ライブラリは不要

Cosmos SDKv3 での Bulk import

実装方法

CosmosClient の初期化時に options で `` をつければ Bulk で実行される。

        var cosmosOptions = new CosmosClientOptions
        {
            AllowBulkExecution = true,
            SerializerOptions = new CosmosSerializationOptions
            {
                PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase,
            }
        };

実装サンプルはこんな感じ。

‘‘‘cs public class BulkImprtSample { private const string ConnectionString = "AccountEndpoint=https://cosmos-beachside-sandbox.documents.azure.com:443/;AccountKey=WeVjCCNtKdl851yBHuy2Vz3xOKXMUAClnVgMEPtS9xcUlFeIiJrbkfsLs93J5PYKY2QqOHxjQkLA8j095Imlqw==;"; private const string DatabaseName = "SampleDb"; private const string ContainerName = "Container2"; private const int AmountotoInsert = 100;

internal async Task RunBulkImportAsync()
{
    var itemsToInsert = Item.GenerateItems(AmountotoInsert);

    var cosmosOptions = new CosmosClientOptions
    {
        AllowBulkExecution = true,
        SerializerOptions = new CosmosSerializationOptions
        {
            PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase,
        }
    };

    var cosmosClient = new CosmosClient(ConnectionString, cosmosOptions);
    var container = cosmosClient.GetContainer(DatabaseName, ContainerName);
    var tasks = new List<Task>(AmountotoInsert);

    var stopwatch = new Stopwatch();
    stopwatch.Start();

    foreach (var item in itemsToInsert)
    {
        tasks.Add(container.UpsertItemAsync(item, new PartitionKey(item.Id))
            .ContinueWith(itemResponse =>
            {
                if (!itemResponse.IsCompletedSuccessfully)
                {
                    AggregateException innerExceptions = itemResponse.Exception.Flatten();
                    if (innerExceptions.InnerExceptions.FirstOrDefault(innerEx => innerEx is CosmosException) is CosmosException cosmosException)
                    {
                        Console.WriteLine($"Received {cosmosException.StatusCode} ({cosmosException.Message}).");
                    }
                    else
                    {
                        Console.WriteLine($"Exception {innerExceptions.InnerExceptions.FirstOrDefault()}.");
                    }
                }
            }));
    }

    await Task.WhenAll(tasks);

    stopwatch.Stop();
    Console.WriteLine($"処理時間: {stopwatch.ElapsedMilliseconds}");

}

}

public class Item { public string Id { get; set; } public string Description { get; set; }

public static IReadOnlyCollection<Item> GenerateItems(int amount)
{
    return new Faker<Item>()
        .StrictMode(true)
        .RuleFor(i => i.Id, f => Guid.NewGuid().ToString())
        .RuleFor(i => i.Description, f => f.Internet.UserName())
        .Generate(amount);
}

}



### ドキュメント
やり方は matias が公式ブログで書いてる:

[https://devblogs.microsoft.com/cosmosdb/introducing-bulk-support-in-the-net-sdk/:embed:cite]


実装方法は Microsoft Learn でのドキュメントをみるとよい。


[https://learn.microsoft.com/ja-jp/azure/cosmos-db/nosql/tutorial-dotnet-bulk-import:embed:cite]

GitHub Actions で Azure API Management の CI/CD (ASP.NET Core 6)

Azure API Management の裏に Web API があると、Web API の CI/CD と一緒に API Management の APIs も更新したいですよね。

公式ドキュメント ではめんどくさそうな実現方法が書かれていますが、APIs だけ更新したいなら Azure CLI で実現するのがシンプルでよいと思っています。

今回は ASP.NET Core 6 の Web API でやりますが Open API のファイルを出力できるならどれでも一緒です。GitHub Actions でのざっくりな流れは以下です。

  • Web API をビルド時に Open API (Swagger) の定義ファイルを出力する
  • Web API をデプロイする
  • Open API (Swagger) の定義ファイルをもとに API Management の APIs を更新する

az apim api import を使うときに知っておきたいこと

Azure API Management の APIs の更新は Azure CLI でやります。

使うコマンドのドキュメントは以下です。ドキュメント通りにコマンドを実行すればよいだけに見えて説明が薄く闇深いので、気になる点を書いていきます。

az apim api import (az apim api) | Microsoft Learn

az apim api import の使い方と闇

APIM の APIs を更新したい場合は、先述のドキュメントで書かれている必須のパラメーター4つの他に、最低限必要なものがあります。

※ ちなみにこれは2022年10月現在の状況なのでバグだったら直るかもしれません。

  • --api-id : 名称通り APIs のユニークな ID です。指定しないと毎回ランダムな値がセットされてしまいます 。つまり別の APIs ができることになる。そして既存の APIs の"path" が重複していると怒られる。同じ APIs を更新する際は事実上必須です。
  • --display-name: セットしないと更新時に "MY API" に書き換えられるため事実上必須と言ってよいでしょう (これはバグな気がする) 。
  • --service-url: セットしないと更新時に空になるので事実上必須です (これはバグな気がする) 。

それぞれのパラメーターが Azure Portal の APIM のリソース (APIs → 対象の API をクリック → Settings タブ) でどれにあたるかを補足しておくとこんな感じです。

パラメーター Azure Portal での値
--display-name Display name (①)
--api-id Name (②)
--service-url Web service URL (③)
--path API URL suffix (④)
--service-name API Management のリソース名

他のオプショナルなパラメーターは必要に応じてつける感じです。

ローカルでコマンドを実行するサンプル

前置きが長くて集中力が低めの私ですが、先述の内容を加味してコマンドのフォーマットは以下です。

az apim api import --resource-group <RESOURCE GROUP> --service-name <APIM SERVICE NAME> --display-name <DISPLAY NAME> --api-id <NAME> --service-url <SERVICE URL> --path <API URL SUFFIX> --specification-format OpenApiJson --specification-path <FILE PATH>

実行の例はこんな感じ。

前提知識: GitHub Actions 実行前に知ってきたい基礎

もう GitHub Actions で動かしたいお気持ちですが、最低限 ? 知っておかなあかん前提知識を2つあげます。

1つ目: CI 時に ASP.NET Core 6 で OpenAPI の定義ファイルを出力

これを書くと長くなるので前々回のブログにまとめました。

blog.beachside.dev

2つ目: GitHub Actions での Azure へのログインは OIDC

昨年 OIDC がサポートされてからは、基本的に OIDC でのログインがベストプラクティスです。ということでここも一緒に書くと長くなりそうだったので前回のブログに書きました。

blog.beachside.dev

GitHub Actions での実行

事前に以下の2つがやってある前提で話を進めます。

  • "前提知識" の "2つ目: GitHub Actions で Azure へのログイン" で示した OIDC の設定。
  • デプロイ先の API Management と Web App のリソースの作成が作成。

YAML のサンプル

コアな部分は先述で書いているので説明は省きますが、シンプルに組み合わせた基本的なコードです。

※ 14 - 25行目の env 定義は私の環境のものなので各々で試すときは変更が必要です。

この YAML での注意点

シンプルなサンプルなので、プロダクションで利用するにあたり考慮してない点をいくつか書いておきます。

  • 本質からちょっと脱線する Web API は build 時に zip した方が速くね?とか Service URL は動的にとってきた方がいんじゃねとかそういう最適化も行ってないです。Web App のデプロイ時に Blue/Green デプロイするとか、要所で Environment 使って承認をさせるとかはもちろん触れてません。
  • APIM の APIs を作成するときの subscription とかの考慮もしてないです。

参考

GitHub Actions で OIDC を使って Azure にログインする

別のブログを書いてる時にこの操作が必要になったんですが、そっちで書くと主旨からずれるのでここで書くことにしました。

GitHub Actions で Azure へログインする際、従来は Azure 側で publish profile を使ったり secret をつかったりでしたが、有効期限があるので期限切れになったら更新するという管理が必要でした。

そんな最中 OpenID Connect (OICD) でのログインが昨年サポートされました。

github.blog

OIDC でのログインにすると、ログインする度に短期の有効期限付きトークンを取得して認証するので、期限切れによる管理が不要になり幸せになります。

ということでやっていきましょう。

Azure Portal での操作

Federated identity credential (フェデレーション ID 資格情報) の作成

Federated identity credential は、Azure AD の App registrations から作成します。

ということで、デプロイなどの操作をしたい リソースがあるサブスクリプションの Azure Portal を開き、Azure AD のリソースを開きます。よくわからない方は検索バーをクリックすれば Azure AD のアイコンがきっとあるでしょう。無ければ検索すれば出てきます。

App registrations をクリック → New registration をクリックします。

Name は適宜入力します。あとで使うので覚えておきましょう。ここでは「sample-federated-identity-credential」としました。 また、Supported account types は一番上の single tenant のを選び、Register ボタンをクリックします。

作成したアプリの画面が開きますので、Certificates & secrets (①) > Federated credentials (②) > Add credential (③) をクリックします。

表示された画面で GitHub の情報を入力します。悩みそうなのは Entity type くらいです。これは大事な部分なので、 こちらのドキュメント でどれにすべきか確認しましょう。 入力したら下部の Add ボタンをクリックして追加します。

最後に GitHub Actions 側の secret に登録する以下の3つの情報を取得します。

  • Application (client) ID
  • Directory (tenant) ID
  • Subscription ID

まずは今開いている app の Overview をクリックし以下の2つをメモしておきます。

  • Application (client) ID
  • Directory (tenant) ID

最後に Subscription ID ですが、操作したいリソースグループの Overview を開けば見えるので手順は書きません。

Contributor ロールの割り当て

GitHub Actions からデプロイとかの操作をしたいリソース対して、作成した credential に Contributor ロールを割り当てます。今回はリソースグループに割り当てます。そうするとリソースグループの中にあるリソース全てに適用されます。個別のリソースに割り当てることももちろん可能です。

Azure Portal で、操作したいリソースグループを開き Access control (①) → Add (②) → Add role assignment (③) をクリックします。

Role タブが表示されるので Contributor をクリックして Next ボタンをクリックします。

先ほど作成した service principal「sample-federated-identity-credential」をメンバーに追加します。

後は、Review + assign ボタン をクリックして追加します。こんな感じで必要なリソースを操作できる権限を割り当てることができます。

GitHub での操作

Secret の登録

先ほど Azure 側で取得した3つの情報を secret に保存します。

GitHub の repo で Settings (③) をクリック → Secret > Actions (②) をクリック → New repository secret をクリックします。これで1つづ3つの情報を登録します。

今回はこんなキーで登録しました。

GitHub Actions を構成する

GitHub Actions はログインして az コマンドでリソースを見るだけのシンプルな構成にしました。

name: Azure login using OIDC sample

on: [push]

permissions:
      id-token: write
      contents: read
      
jobs: 
  oidc-login:
    runs-on: ubuntu-latest

    steps:
        - name: OIDC Login to Azure
          uses: azure/login@v1
          with:
            client-id: ${{ secrets.AZURE_FIC_CLIENT_ID }}
            tenant-id: ${{ secrets.AZURE_FIC_TENANT_ID }}
            subscription-id: ${{ secrets.AZURE_FIC_SUBSCRIPTION_ID }} 

        - name: run commands
          run: |
            az account show
            az group list -o table

実行すると、GitHub Actions のログでは正常に Azure の情報が取れてることがわかります。

まとめ

OIDC でのログインをすることで、従来の secret の期限切れを意識する必要もなくなり、トークン自体は使ったら捨てる感じなのでセキュアになるので、今後は基本的にこの方法で Azure へログインするのが良いです。

GitHub Actions の azure/login@v1 の細かい構成方法は、以下のドキュメントに書かれていますので、実際に使う際は必ず確認しましょう。

github.com

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