概要
Azure AppServiceのAppService認証(EasyAuth)を使うと、Webアプリに簡単にEntraID認証を実装できます。
実際のWebアプリでは、ログイン認証の後に更に他のAPI、例えばAzure FoudryのAIサービスなどにユーザーの権限でアクセスしたい場合があります。
ここで、ログイン(EasyAuth)に使用したアクセストークンを使ってAPIを呼び出すことができればスマートです。
今回は、OBOフロー(OnBeHalfOf)を使うことで新たな認証画面を出すことなく、目的のサービスのアクセストークンを取得できることが分かりましたので具体的な方法をまとめてみます。
例題
AppService認証で取得したアクセストークンをベースに、OBOフローでAzure FoudryのAIサービスにユーザーとしてアクセスするトークンに交換し、gptモデルを呼び出します。
細かい表現が間違えているかもしれませんが、参考になれば幸いです。
※ユーザーの権限でアクセスしたい理由がなければマネージドIDを使ってアクセスすることが多いです。
参考記事
利用技術
On-Behalf-Ofフロー
- OBOフローは、ユーザーのアクセストークンを使って、別のAPIのアクセストークンを取得するフローです。
- 今回の例ではAppService認証で取得したアクセストークンをベースとして、Azure Foudry(他のAPI)へのアクセストークンと交換します。
EntraIDアプリ
- AppService認証を利用するために必要です。
- APIの公開設定、APIのアクセス許可のカスタムが必要です。
AppService認証(EasyAuth)
- Webアプリ側に認証機構を設けなくてもAppServiceがリバースプロキシとなり、簡単にEntraID認証を構築できます。
- OBOフローを利用するためにはAppService認証で付与されるアクセストークンをカスタムする必要があります。
Blazor Server
- 今回のWebアプリのサーバーサイド処理に利用
- .NETで開発します
設定方法の解説
AppServiceの設定
認証設定(EntraID認証)
- 認証→プロバイダの追加でMicrosoft Entra IDを選択しEntraID認証を設定します。
- EntraIDアプリをここで新規作成するのが簡単ですが、既存のEntraIDも指定できます。
- その他のEntraIDアプリのカスタムは次項目で説明します
設定前情報の確認
-
https://<AppServiceのURL>/.auth/meにアクセスすると、AppService認証で付与されたアクセストークンを確認できます。 -
access_tokenプロパティのJWTをデコードすると、audがGraphAPIのものになっていることが確認できます。- DevToysなどのツールでJWTデコードすると簡単です。
- GraphAPIに対するアクセストークンではOBOフローでトークン交換ができないので、audを自分のEntraIDアプリのIDに変更する必要があります。
AppService認証で付与されるトークンを自分のアプリIDに変更
- AppServiceのGUIではカスタムできないので、
Azure Resource Explorerで編集します。 -
authsettingsV2を開き、loginParametersにスコープを追加することで、audをEntraIDアプリのIDに変更します。 - Resource Explorerで次を開きます。検索欄にAppServiceのリソースIDを入力して検索すると簡単に開けます。
https://management.azure.com/subscriptions/{TenantID}/resourceGroups/{ResourceGroup}/providers/Microsoft.Web/sites/{AppServiceName}/config/authsettingsV2/list?api-version=2020-12-01
- loginパラメータを編集してscopeをカスタムします。
- GraphAPI固有のUser.Readを入れると共存できずエラーになるので注意してください。
"login": {
"loginParameters": [
"scope=openid profile email offline_access api://{AppId}/user_impersonation"
],
"disableWWWAuthenticate": false
}
AppService認証の設定追加
-
許可されるトークン対象ユーザーに、先ほどのapi://{AppId}/を追加します。 - これにより上記で編集したアプリIDで認証が可能になります。
設定後情報の確認
- AppServiceのページで一旦
.auth/logoutでログアウトしてから、再度AppService認証を行い.auth/meにアクセスします。 -
access_tokenプロパティのJWTをデコードすると、audが自分のEntraIDアプリのIDになっていることが確認できます。 - そして、scp(スコープ)も
user_impersonationになっていることが確認できます。
EntraIDアプリの設定
APIの公開
- スコープの追加から
user_impersonationスコープを追加します。- AppServiceからアプリ作成した場合は自動で追加されています。
- スコープを公開することで、OBOフローでトークンを交換する際に必要なaudを取得できる
APIのアクセス許可
-
アクセス許可の追加→所属する組織で使用している APIの一覧から目的のAPIを探します。 - 自身のEntraIDアプリの
user_impersonationスコープに対してアクセス許可を付与します。 - 次に、今回使用する
Microsoft Cognitive ServicesのAPIに対してアクセス許可を付与します。-
user Impersonationのアクセス許可を付与する
-
OBOフローでアクセストークンを取得するコード
MSALライブラリのAcquireTokenOnBehalfOfを使用します。
// EntraIDのクライアントID、テナントID、クライアントシークレットからMSALクライアントを作成(機密クライアント用)
IConfidentialClientApplication app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithClientSecret(clientSecret)
.WithAuthority($"https://login.microsoftonline.com/{tenantId}")
.Build();
// OBOフローでアクセストークンを取得
// assertionとしてトークン交換のベースになるアクセストークンを指定
// 条件として、userAccessTokenのaudとappのclientIdが一致していること。
// また、clientIdのEntraIDアプリでscopesに指定したスコープが許可されていること。
AuthenticationResult authResult = await app.AcquireTokenOnBehalfOf(
scopes,
// ユーザーアクセストークンをアサーションとして渡す
new UserAssertion(userAccessToken))
.ExecuteAsync(cancellationToken);
// AccessTokenに変換
var accessToken = new AccessToken(result.AccessToken, result.ExpiresOn);
Blazor Serverでの実装
- .NET Blazor Serverアプリで、OnBehalfOfフローを利用してAzure FoundryのAPIにアクセスするためのコードを実装します。
事前に準備するコード
IUserAccessTokenProvider
- JSを経由して、
.auth/meから取得したアクセストークンを取得するためのインターフェースと実装クラスを作成します。
public interface IUserAccessTokenProvider{
Task<AccessToken> GetAccessTokenAsync();
}
public record EasyAuthTokenDto(string AccessToken, string ExpiresOn);
public sealed class EasyAuthTokenProvider: IUserAccessTokenProvider
{
private readonly IJSRuntime _jsRuntime;
private readonly IWebHostEnvironment _env;
public EasyAuthTokenProvider(IJSRuntime jsRuntime, IWebHostEnvironment env)
{
_jsRuntime = jsRuntime;
_env = env;
}
/// <summary>
/// JS相互作用を使用して、EasyAuthからアクセストークンを取得します。
/// </summary>
/// <returns></returns>
public async Task<AccessToken> GetAccessTokenAsync()
{
EasyAuthTokenDto easyAuthToken;
if (!_env.IsProduction()) {
// 開発環境用のトークン取得コード
// easyAuthToken = ...
return new AccessToken(easyAuthToken.AccessToken, DateTimeOffset.Parse(easyAuthToken.ExpiresOn));
}
// JS相互作用を使用してAuth/Meからアクセストークンを取得する
easyAuthToken = await _jsRuntime.InvokeAsync<EasyAuthTokenDto>("easyAuth.getAccessToken");
return new AccessToken(easyAuthToken.AccessToken, DateTimeOffset.Parse(easyAuthToken.ExpiresOn));
}
}
easyAuth.js
- JavaScriptを使って.auth/meエンドポイントから最新のアクセストークンを取得してBlazor側に渡します。
window.easyAuth = {
// Get the access token from EasyAuth
// this function can be called from blazor code to get the access token from EasyAuth
async getAccessToken() {
const response = await fetch('/.auth/me');
if (!response.ok) {
throw new Error("/.auth/me failed");
}
const data = await response.json();
const aad = data.find(x => x.provider_name === "aad");
if (!aad) {
throw new Error("AAD provider not found");
}
return {
accessToken: aad.access_token,
expiresOn: aad.expires_on
};
}
};
TokenCredential
- AzureサービスSDKでお馴染みのTokenCredentialクラスです。
- TokenCredentialを継承して、OnBehalfOfフローを利用してアクセストークンを取得するクラスを作成します。
- 各AzureサービスのSDKから必要になったタイミングで
GetTokenAsyncが呼ばれますので、OBDフローでアクセストークンを取得して返すように実装します。
/// <summary>
/// OnBehalfOfTokenフローを利用してTokenCredential型を返します
/// 取得済みのAccessTokenとクライアントシークレットを使って別アプリのAccessTokenに交換します
/// </summary>
public sealed class OnBehalfOfTokenCredential : TokenCredential
{
private readonly IConfidentialClientApplication _app;
private IUserAccessTokenProvider _provider;
private AccessToken? _cachedUserAccessToken;
public static OnBehalfOfTokenCredential Create(string clientId, string clientSecret, string tenantId, IUserAccessTokenProvider easyAuthTokenProvider)
{
IConfidentialClientApplication app = CreatedConfidentialClientApplication(clientId, clientSecret, tenantId);
return new OnBehalfOfTokenCredential(app, easyAuthTokenProvider);
}
public OnBehalfOfTokenCredential(IConfidentialClientApplication app, IUserAccessTokenProvider easyAuthTokenProvider)
{
_app = app;
_provider = easyAuthTokenProvider;
}
public override AccessToken GetToken(TokenRequestContext requestContext,CancellationToken cancellationToken)
{
return GetTokenAsync(
requestContext,
cancellationToken)
.GetAwaiter()
.GetResult();
}
/// <summary>
/// OnBehalfOf フローで 既存のユーザーアクセストークンの認可を証拠として
/// 新しいスコープのアクセストークンを取得します
/// </summary>
/// <param name="requestContext"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public override async ValueTask<AccessToken> GetTokenAsync(
TokenRequestContext requestContext,
CancellationToken cancellationToken)
{
// ユーザートークンをがないか、期限切れ前ならトークンを再要求
if (_cachedUserAccessToken == null || _cachedUserAccessToken.Value.ExpiresOn < DateTimeOffset.UtcNow.AddMinutes(5))
{
_cachedUserAccessToken = await _provider.GetAccessTokenAsync();
}
string userAccessToken = _cachedUserAccessToken.Value.Token;
// AcquireTokenOnBehalfOf でアクセストークンを取得
var result = await _app.AcquireTokenOnBehalfOf(
requestContext.Scopes,
// ユーザーアクセストークンをアサーションとして渡す
new UserAssertion(userAccessToken))
.ExecuteAsync(cancellationToken);
return new AccessToken(result.AccessToken, result.ExpiresOn);
}
private static IConfidentialClientApplication CreatedConfidentialClientApplication(string clientId,string clientSecret,string tenantId)
{
var app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithClientSecret(clientSecret)
.WithAuthority($"https://login.microsoftonline.com/{tenantId}")
.Build();
return app;
}
}
Program.cs
- DIの設定を行います
/// ユーザーアクセストークンプロバイダー
builder.Services.AddScoped<IUserAccessTokenProvider,EasyAuthTokenProvider>();
/// OnBehalfOfTokenCredentialの登録
builder.Services.AddScoped<OnBehalfOfTokenCredential>(
sp =>
{
// IUserAccessTokenProviderの実装をDIコンテナから取得
var provider = sp.GetRequiredService<IUserAccessTokenProvider>();
// OnBehalfOfTokenCredentialのインスタンスを作成
return OnBehalfOfTokenCredential.Create(
builder.Configuration["AzureAD:ClientId"]!,
builder.Configuration["AzureAD:ClientSecret"]!,
builder.Configuration["AzureAD:TenantId"]!,
provider
);
}
);
OBOフローの利用方法
テスト用コード
@inject OnBehalfOfTokenCredential _onBehalfOfTokenCredential
private async Task GetOBOToken(MouseEventArgs args)
{
TokenRequestContext tokenRequestContext = new TokenRequestContext(new[] { "https://cognitiveservices.azure.com/.default" });
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
try
{
AccessToken token = await _onBehalfOfTokenCredential.GetTokenAsync(tokenRequestContext, cts.Token);
_alertService.Success($"トークンの取得に成功しました。トークンの有効期限: {token.ExpiresOn}", 5);
}
catch (Exception e)
{
_alertService.Danger("トークンの取得に失敗しました。", 5);
}
}
token.Tokenを参照すると、以下のようなアクセストークンが取得されています。
これでスコープがhttps://cognitiveservices.azure.com/.defaultなAzureサービスを利用できます。
最近はhttps://ai.azure.com/.defaultの場合もありますが、まだAPIのアクセス許可ができません。
{
"aud": "https://cognitiveservices.azure.com",
"...": "...",
"scp": "user_impersonation"
}
Azure FoudryのGPT Response APIを呼び出す例
// DIの追加
builder.Services.AddScoped<AzureAIService>(
sp => {
return new AzureAIService(
builder.Configuration["AzureAIService:Endpoint"]!,
sp.GetRequiredService<OnBehalfOfTokenCredential>()
);
}
);
// Chat-GPTのResponse APIを呼び出す例
// AzureAIServiceクラスの一部
private readonly ResponsesClient _client;
private readonly TokenCredential _credential;
public string DeploymentModel { get; set; } = "gpt-5.4-nano";
private const string _tokenEndpoint = "https://cognitiveservices.azure.com/.default";
public AzureAIService(string endpoint, TokenCredential credential)
{
_endpoint = endpoint;
_credential = credential;
BearerTokenPolicy tokenPolicy = new(_credential,_tokenEndpoint);
ResponsesClientOptions responsesClientOptions = new()
{
Endpoint = new Uri($"{endpoint}")
};
// Create the client with the token policy and endpoint
_client = new ResponsesClient(tokenPolicy,responsesClientOptions);
}
public async Task<ServiceResult<ResponseResult>> ExecuteResponse(
string systemPrompt,
string userPrompt)
{
List<ResponseContentPart> responseContentParts =
[
ResponseContentPart.CreateInputTextPart(userPrompt)
];
return await ExecuteResponseCore(
systemPrompt,
responseContentParts);
}
private async Task<ServiceResult<ResponseResult>> ExecuteResponseCore(string systemPrompt,List<ResponseContentPart> responseContentParts)
{
CreateResponseOptions options = new()
{
Model = DeploymentModel,
ReasoningOptions = new ResponseReasoningOptions()
{
ReasoningEffortLevel = ResponseReasoningEffortLevel.Low,
},
InputItems =
{
ResponseItem.CreateDeveloperMessageItem(systemPrompt),
ResponseItem.CreateUserMessageItem(responseContentParts)
},
};
ResponseResult response = await _client.CreateResponseAsync(options);
return ServiceResult<ResponseResult>.Success(response);
}



