🐾

C#で知るAzure DevOpsのセキュリティ(PAT、サービスプリンシパル+証明書)の実践利用

2023/10/17に公開

はじめに

先日、Agile Specialist の yuriemori さんが登壇された MS MVP の亀川さん主催の TFSUG イベントに参加させていただきました。
その際に得られた貴重な知識を基に C#によるAzure DevOps REST API の操作方法 を主題としてフィードバックさせていただきます。
https://zenn.dev/yuriemori/articles/5644ad3c3f2581

Azure DevOps には REST API が提供されており、いくつかの認証方式があります。

方式 説明 本記事対象
Personal Access Tokens (PATs) 最も一般的な認証方法の1つです。Azure DevOps Servicesで独自のトークンを作成し、それを使用してAPIにアクセスします。これらのトークンは特定の権限と有効期限を持ち、安全にAPIを呼び出すために使用されます。 対象
Basic Authentication 資格情報(ユーザー名とパスワード、またはPersonal Access Token)をHTTPヘッダーに埋め込む方法です。ただし、この方法は安全性の観点から推奨されません。使用する場合は、HTTPS経由でのみAPIを呼び出す必要があります。 対象
Entra ID Integrated Authentication Azure DevOps ServicesとEntra IDを連携させることで、Entra IDの認証機能を使用してAPIにアクセスすることができます。 対象
OAuth 2.0 Azure DevOps ServicesはOAuth 2.0をサポートしており、サードパーティのアプリケーションがユーザーの代わりにAzure DevOps Servicesにアクセスすることを許可します。 対象外

Azure DevOps に対して C# で接続する方式を纏めてみました。

本記事の ソースコードは、Git に登録しています。
https://github.com/yutaka-art/AzDORestApiInspections

1. Personal Access Tokens (PATs)+Basic Authentication

Personal Access Token は最も一般的に使用される認証手法です。有効期限がMax 1年とローテンションの頻度が高いことに加えて自動的な更新方法が存在しないことから、担当者の退職などによる運用上のリスクがあります。
また、OAuth 2.0 や OpenID Connect のような標準的な認証手法とは異なり、PAT は Azure DevOps 特有のものとなります。

シーケンス図

PAT の作成方法は下記を参照ください。
https://learn.microsoft.com/ja-jp/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows

C# で PAT を利用するサンプルを書いてみました。このサンプルは、Microsoft.TeamFoundationServer.Client ライブラリを使用して接続を確立しています。appsettings.json に対して、PAT と Organization を設定し起動することで、WorkItem の 参照 または 作成 が可能です。

PersonalAccessTokenInspection
Program.cs
using Microsoft.Extensions.Configuration;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.WebApi;
using Microsoft.VisualStudio.Services.WebApi.Patch;
using Microsoft.VisualStudio.Services.WebApi.Patch.Json;

namespace PersonalAccessTokenInspection
{
    /// <summary>
    /// PersonalAccessTokenInspection 検証クラス
    /// </summary>
    public class Program
    {
        /// <summary>
        /// エントリポイント
        /// </summary>
        /// <param name="args"></param>
        /// <returns></returns>
        public static async Task<int> Main(string[] args)
        {
            // 設定ファイル読込
            var configuration = new ConfigurationBuilder()
                .AddJsonFile($"appsettings.json");
            var config = configuration.Build();

            // 設定ファイルより、OrganizationURLとPATを取得
            var orgUrl = new Uri(config["OrgUrl"]);
            var pat = config["PersonalAccessToken"];

            // PATを利用しクレデンシャルを確立
            var credentials = new VssBasicCredential(string.Empty, pat);
            var connection = new VssConnection(orgUrl, credentials);

            // Connect
            var client = connection.GetClient<WorkItemTrackingHttpClient>();

            Console.WriteLine("Enter the mode (get/create):");
            var command = Console.ReadLine().ToLowerInvariant();

            Console.WriteLine("Enter the project name:");
            var project = Console.ReadLine();

            // Mode=get の場合、IDを指定して既存のWorkItem#を取得しタイトルを返却
            if (command == "get")
            {
                Console.WriteLine("Enter the WorkItem ID:");
                var id = int.Parse(Console.ReadLine());

                // 下記の実体はBasic Authentication となり、HTTPヘッダーにPATが埋め込まれてAzure DevOps REST API をCallしています
                var workItem = await client.GetWorkItemAsync(project, id);
                Console.WriteLine(workItem.Fields["System.Title"]);
            }
            // Mode=create の場合、Titleを指定して新規にWorkItemを生成
            else if (command == "create")
            {
                Console.WriteLine("Enter the title for the new WorkItem:");
                var title = Console.ReadLine();

                var patchDocument = new JsonPatchDocument
                {
                    new JsonPatchOperation()
                    {
                        Operation = Operation.Add,
                        Path = "/fields/System.Title",
                        Value = title
                    }
                };

                try
                {
                    // 下記の実体はBasic Authentication となり、HTTPヘッダーにPATが埋め込まれてAzure DevOps REST API をCallしています
                    var result = await client.CreateWorkItemAsync(patchDocument, project, "task");
                    Console.WriteLine($"work item created: Id = {result.Id}");
                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex.Message);
                    return -1;
                }
            }
            else
            {
                Console.WriteLine("Invalid mode entered.");
                return -1;
            }

            return 0;
        }
    }
}
appsettings.json
{
  "PersonalAccessToken": "{insert PAT here}",
  "OrgUrl": "https://dev.azure.com/{insert Organization here}/"
}

2. Entra ID Integrated Authentication(Azure Active Directory (Azure AD))

続いて、Entra ID Integrated Authentication です。
こちらもユーザの介入無しで自動認証が可能であり、バックエンドサービスや CI/CD パイプラインなどの認証に適しています。また、Entra ID の RBAC (Role-Based Access Control) を使用して、特定のリソースへのアクセス権を細かく制御できることや、トークンベースのセキュリティとなることから PAT と比較しより安全な認証手段を提供します。

2.1. Service Principal(Client Secret)

まずは、Client Secret を利用するパターンを説明します。

シーケンス図

こちらはステップバイステップで説明していきます。

2.1.1. Entra ID へ サービスプリンシパルを作成する

Azure Portal から Entra ID を選択し、アプリの登録をします。

+新規登録 をクリックし、任意の名前を入力し、登録をクリックします。

アプリが作成されるので、表示されている、アプリケーション (クライアント) IDディレクトリ (テナント) IDをメモしておきましょう。※あとからプログラムに登録します。
続いて、証明書とシークレットをクリックします。

+新しいクライアント シークレットをクリックします。

説明と、有効期限を設定し、追加をクリックします。

シークレット値をメモしておきましょう。この値は閉じると二度と表示されませんが、忘れてしまっても異なる値となりますが再度作り出すことができます。

Entra ID への設定は以上です。

2.1.2. Azure DevOps へ 作成したサービスプリンシパルを登録する

Azure DevOps の Organization Settings を開き、Users の Add users をクリックします。

画面右側に表示されるので、下記のとおり指定します。

User or Service Principals:Entra ID に追加したサービスプリンシパルを指定します。
Access level:適切な 値を設定してください。
Add to projects:このサービスプリンシパルが参照するProjectを指定します。
Azure DevOps Groups:Project Contributorsを指定します。
※プログラムでWorkItemの参照と書き込みを行うため
Send email invites (to Users only):チェックOff
※サービスプリンシパルはメールアドレスを持たないため不要です。

Addをクリックします。

正常に追加されていることを確認します。以上で、Azure DevOps 側の設定は終了です。

2.1.3. C# で サービスプリンシパル(Client Secret)を利用してAzure DevOps REST APIを操作する

C# で サービスプリンシパル(Client Secret) を利用するサンプルを書いてみました。このサンプルもPATの時と同様に、Microsoft.TeamFoundationServer.Client ライブラリを使用して接続を確立しています。appsettings.json に対して、TenantId と ClientId と ClientSecret と Organization を設定し起動することで、WorkItem の 参照 または 作成 が可能です。

ServicePrincipalInspection
Program.cs
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
using Microsoft.VisualStudio.Services.Client;
using Microsoft.VisualStudio.Services.WebApi;
using Microsoft.VisualStudio.Services.WebApi.Patch;
using Microsoft.VisualStudio.Services.WebApi.Patch.Json;

namespace ServicePrincipalInspection
{
    /// <summary>
    /// ServicePrincipalInspection 検証クラス
    /// </summary>
    public class Program
    {
        /// <summary>
        /// エントリポイント
        /// </summary>
        /// <param name="args"></param>
        /// <returns></returns>
        public static async Task<int> Main(string[] args)
        {
            // 設定ファイル読込
            var configuration = new ConfigurationBuilder()
                .AddJsonFile($"appsettings.json");
            var config = configuration.Build();

            // 設定ファイルより取得
            var orgUrl = new Uri(config["OrgUrl"]);
            var tenantId = config["TenantId"];
            var clientId = config["ClientId"];
            var clientSecret = config["ClientSecret"];

            // Azure DevOps のサービスプリンシパル認証の際に必要なスコープを示す固定の文字列
            // ※appsettings.json のような設定ファイルに持たせてもよい
            const string azureDevOpsAppScope = "499b84ac-1321-427f-aa17-267ca6975798/.default";

            // サービスプリンシパルのTenantId,ClientId,ClientSecretを利用しアクセストークンを取得
            var credentails = new ClientSecretCredential(tenantId, clientId, clientSecret);
            var accessToken = await credentails.GetTokenAsync(new Azure.Core.TokenRequestContext(new[] {azureDevOpsAppScope} ));

            var vssAadToken = new VssAadToken("Bearer", accessToken.Token);
            // 取得したトークンでクレデンシャルを確立
            var vssAadCredentials = new VssAadCredential(vssAadToken);

            // Connect
            var connection = new VssConnection(orgUrl, vssAadCredentials);

            var client = connection.GetClient<WorkItemTrackingHttpClient>();

            Console.WriteLine("Enter the mode (get/create):");
            var command = Console.ReadLine().ToLowerInvariant();

            Console.WriteLine("Enter the project name:");
            var project = Console.ReadLine();

            // Mode=get の場合、IDを指定して既存のWorkItem#を取得しタイトルを返却
            if (command == "get")
            {
                Console.WriteLine("Enter the WorkItem ID:");
                var id = int.Parse(Console.ReadLine());

                var workItem = await client.GetWorkItemAsync(project, id);
                Console.WriteLine(workItem.Fields["System.Title"]);
            }
            // Mode=create の場合、Titleを指定して新規にWorkItemを生成
            else if (command == "create")
            {
                Console.WriteLine("Enter the title for the new WorkItem:");
                var title = Console.ReadLine();

                var patchDocument = new JsonPatchDocument
                {
                    new JsonPatchOperation()
                    {
                        Operation = Operation.Add,
                        Path = "/fields/System.Title",
                        Value = title
                    }
                };

                try
                {
                    var result = await client.CreateWorkItemAsync(patchDocument, project, "task");
                    Console.WriteLine($"work item created: Id = {result.Id}");
                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex.Message);
                    return -1;
                }
            }
            else
            {
                Console.WriteLine("Invalid mode entered.");
                return -1;
            }

            return 0;
        }
    }
}
appsettings.json
{
  "TenantId": "{insert TenantId here}",
  "ClientId": "{insert ClientId here}",
  "ClientSecret": "{insert ClientSecret here}",
  "OrgUrl": "https://dev.azure.com/{insert Organization here}/"
}

2.2. Service Principal(Certificate)

続いて、証明書(Certificate) を利用するパターンを説明します。
このアプローチの主な違いは、Client Secretの代わりに証明書を使用してトークンを要求することです。これにより、証明書を保有しているアプリケーションのみがトークンを要求できるようになり、セキュリティが向上するメリットがあります。

シーケンス図

2.2.1. 自己証明書の作成

まずは、Entra ID に登録するための自己証明書(オレオレ証明書)を作成します。

powershell
New-SelfSignedCertificate -Subject "CN=AzureDevOpsSPCert" -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable -KeySpec Signature

コマンドにより、証明書がインポートされているので、certmgr を開き、任意の場所へエクスポートします。

2.2.2. Azure Entra サービスプリンシパルへアップロード

続いて、作成した証明書をAzure Entra のサービスプリンシパルへアップロードします。

Azure Entra を開き、証明書とシークレット、↑証明書のアップロードをクリックします。

証明書をアップロードし、追加をクリックします。

正常に証明書がアップロードされたことを確認します。
また、拇印(Thumbprint)をプログラムで利用するので、コピーしメモしておきましょう。

2.2.3. C# で サービスプリンシパル(Certificate)を利用してAzure DevOps REST APIを操作する

C# で サービスプリンシパル(Certificate) を利用するサンプルを書いてみました。このサンプルもPATの時と同様に、Microsoft.TeamFoundationServer.Client ライブラリを使用して接続を確立しています。appsettings.json に対して、TenantId と ClientId と ClientCertificateThumbprint と Organization を設定し起動することで、WorkItem の 参照 または 作成 が可能です。

ServicePrincipalCertificateInspection
Program.cs
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.TeamFoundation.WorkItemTracking.WebApi;
using Microsoft.VisualStudio.Services.Client;
using Microsoft.VisualStudio.Services.WebApi;
using Microsoft.VisualStudio.Services.WebApi.Patch;
using Microsoft.VisualStudio.Services.WebApi.Patch.Json;
using System.Security.Cryptography.X509Certificates;

namespace ServicePrincipalCertificateInspection
{
    /// <summary>
    /// ServicePrincipalCertificateInspection 検証クラス
    /// </summary>
    public class Program
    {
        /// <summary>
        /// エントリポイント
        /// </summary>
        /// <param name="args"></param>
        /// <returns></returns>
        public static async Task<int> Main(string[] args)
        {
            // 設定ファイル読込
            var configuration = new ConfigurationBuilder()
                .AddJsonFile($"appsettings.json");
            var config = configuration.Build();

            // 設定ファイルより取得
            var orgUrl = new Uri(config["OrgUrl"]);
            var tenantId = config["TenantId"];
            var clientId = config["ClientId"];
            var certificateThumbprint = config["ClientCertificateThumbprint"];

            // Azure DevOps のサービスプリンシパル認証の際に必要なスコープを示す固定の文字列
            // ※appsettings.json のような設定ファイルに持たせてもよい
            const string azureDevOpsAppScope = "499b84ac-1321-427f-aa17-267ca6975798/.default";

            // 証明書ストア.現在ユーザより証明書情報を取得
            using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
            store.Open(OpenFlags.ReadOnly);
            var certificate = store.Certificates.Cast<X509Certificate2>().FirstOrDefault(cert => cert.Thumbprint == certificateThumbprint);

            // サービスプリンシパルのTenantId,ClientId,Thumbprint(拇印)を利用しアクセストークンを取得
            var credentails = new ClientCertificateCredential(tenantId, clientId, certificate);
            var accessToken = await credentails.GetTokenAsync(new Azure.Core.TokenRequestContext(new[] { azureDevOpsAppScope }));

            var vssAadToken = new VssAadToken("Bearer", accessToken.Token);
            // 取得したトークンでクレデンシャルを確立
            var vssAadCredentials = new VssAadCredential(vssAadToken);

            // Connect
            var connection = new VssConnection(orgUrl, vssAadCredentials);

            var client = connection.GetClient<WorkItemTrackingHttpClient>();

            Console.WriteLine("Enter the mode (get/create):");
            var command = Console.ReadLine().ToLowerInvariant();

            Console.WriteLine("Enter the project name:");
            var project = Console.ReadLine();

            // Mode=get の場合、IDを指定して既存のWorkItem#を取得しタイトルを返却
            if (command == "get")
            {
                Console.WriteLine("Enter the WorkItem ID:");
                var id = int.Parse(Console.ReadLine());

                var workItem = await client.GetWorkItemAsync(project, id);
                Console.WriteLine(workItem.Fields["System.Title"]);
            }
            // Mode=create の場合、Titleを指定して新規にWorkItemを生成
            else if (command == "create")
            {
                Console.WriteLine("Enter the title for the new WorkItem:");
                var title = Console.ReadLine();

                var patchDocument = new JsonPatchDocument
                {
                    new JsonPatchOperation()
                    {
                        Operation = Operation.Add,
                        Path = "/fields/System.Title",
                        Value = title
                    }
                };

                try
                {
                    var result = await client.CreateWorkItemAsync(patchDocument, project, "task");
                    Console.WriteLine($"work item created: Id = {result.Id}");
                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex.Message);
                    return -1;
                }
            }
            else
            {
                Console.WriteLine("Invalid mode entered.");
                return -1;
            }

            return 0;
        }
    }
}
appsettings.json
{
  "TenantId": "{insert TenantId here}",
  "ClientId": "{insert ClientId here}",
  "ClientCertificateThumbprint": "{insert Thumbprint here}",
  "OrgUrl": "https://dev.azure.com/{insert Organization here}/"
}

まとめ

TeamFoundationServer.Client ライブラリを使用することで、必要な情報を提供するだけで簡単に認証を確立できることがわかりました。しかし、アクセストークンを取得するテナントごとのエンドポイントのAPIバージョンが異なる場合、アクセストークンの文字列が異なる(または減少する)という問題に直面しました。HTTPでアクセストークンを取得する際のエンドポイントは、https://login.microsoftonline.com/$tenant_id/oauth2/v2.0/token であることに注意してください。

Personal Access Token から Service Principal への移行を検討しているエンジニアの方々の参考になれば幸いです。

References

https://learn.microsoft.com/ja-jp/azure/devops/integrate/get-started/authentication/service-principal-managed-identity?view=azure-devops

GitHubで編集を提案

Discussion