WebAPI はバックエンドとして利用されるため、ユーザーのサインイン画面を Web API として出す機会がありません。そのため以下のフローを取ります。
- 独自 WebAPI 自体を Azure AD でセキュアにする
- クライアントが WebAPI を利用する際に Azure AD にて認証し、トークンを Web API に渡す
- WebAPI が受け取ったユーザー情報を元に、代理でアクセストークンを取得する
今回は以下のアプリケーションを作ります。
- C# ASP.NET WebAPI
- C# コンソールアプリケーションクライアント
- 代理認証シナリオ
- MSAL を利用
WebAPI 用のアプリケーション登録
OAuth 2.0 を使うために、まず初めにアプリケーションを登録します。
1. Microsoft アプリ登録ポータル にアクセスして、ログイン。ここでは組織アカウントを使用。
2. 新規にアプリケーションを作成。ここでは名前を「Graph WebAPI Demo Application」と設定。プラットフォームの項目で「プラットフォームの追加」をクリック。Web API を選択。
3. 作成されたら、「アプリケーション ID URI」を確認。スコープは自動で access_as_user が作成される。
4. 一旦アプリケーションを保存。その後以下アドレスにアクセスして管理者権限で一度承認を実行。これは作成したアプリケーションを Azure AD 側に登録するためだが、本来であれば自動でできてほしい。
※client_id は登録したものに変更
https://login.microsoftonline.com/graphdemo01.onmicrosoft.com/adminconsent
?client_id=1bc3f11c-5074-407c-8c9c-8b2d20933db6
&state=12345
&redirect_uri=http://localhost
5. 以下のエラーが出るが、登録したいだけなので、問題はない。
クライアント用のアプリケーション登録
もう 1 つクライアント用のアプリケーションを登録します。実際のシナリオでは WebAPI とクライアントが、異なるアプリケーションになる可能性が高いため、テストとしては上記と同じアプリケーションに「ネイティブアプリケーション」を登録してもいいのですが、ここでは別に登録します。
1. 新規にアプリケーションを登録。名前は「Graph WebAPI Demo Client Application」を指定。プラットフォームの項目で「プラットフォームの追加」をクリックし「ネイティブアプリケーション」を選択。
2. 「Microsoft Graph のアクセス許可」に既定である「User.Read」を削除。アプリケーション ID をコピーしておき、「保存」をクリック。
これでアプリケーション自体は Microsoft Graph にアクセスはできません。
アプリケーションの開発
ここでは、まず WebAPI を Azure AD v2 エンドポイントでセキュアにするところまで作成します。
WebAPI の開発
1. Visual Studio より新しい ASP.NET Web アプリケーションを作成。
2. 「Web API」を選択し、認証は「個別のユーザーアカウント」を指定。
3. NuGet の管理より「Microsoft.Identity.Clinet」、「Microsoft.Owin.Security.OpenIdConnect 3.1.0」、「Microsoft.Owin.Security.Jwt 3.1.0」を追加。MSAL はプレビューのため、「プレリリースを含める」にチェック。
4. App_Start フォルダに OpenIdConnectCachingSecurityTokenProvider.cs ファイルを追加し、以下のコードで差し替え。このクラスはトークンフォーマットの指定に必要。
using Microsoft.IdentityModel.Protocols;
using Microsoft.Owin.Security.Jwt;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.Threading;
namespace GraphWebAPIDemo.App_Start
{
public class OpenIdConnectCachingSecurityTokenProvider : IIssuerSecurityTokenProvider
{
public ConfigurationManager<OpenIdConnectConfiguration> _configManager;
private string _issuer;
private IEnumerable<SecurityToken> _tokens;
private readonly string _metadataEndpoint;
private readonly ReaderWriterLockSlim _synclock = new ReaderWriterLockSlim();
public OpenIdConnectCachingSecurityTokenProvider(string metadataEndpoint)
{
_metadataEndpoint = metadataEndpoint;
_configManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataEndpoint);
RetrieveMetadata();
}
/// <summary>
/// Gets the issuer the credentials are for.
/// </summary>
/// <value>
/// The issuer the credentials are for.
/// </value>
public string Issuer
{
get
{
RetrieveMetadata();
_synclock.EnterReadLock();
try
{
return _issuer;
}
finally
{
_synclock.ExitReadLock();
}
}
}
/// <summary>
/// Gets all known security tokens.
/// </summary>
/// <value>
/// All known security tokens.
/// </value>
public IEnumerable<SecurityToken> SecurityTokens
{
get
{
RetrieveMetadata();
_synclock.EnterReadLock();
try
{
return _tokens;
}
finally
{
_synclock.ExitReadLock();
}
}
}
private void RetrieveMetadata()
{
_synclock.EnterWriteLock();
try
{
OpenIdConnectConfiguration config = _configManager.GetConfigurationAsync().Result;
_issuer = config.Issuer;
_tokens = config.SigningTokens;
}
finally
{
_synclock.ExitWriteLock();
}
}
}
}
5. Startup.Auth.cs を以下のコードに差し替え。
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Google;
using Microsoft.Owin.Security.OAuth;
using Owin;
using GraphWebApiDemo.Providers;
using GraphWebApiDemo.Models;
using System.Configuration;
using Microsoft.Owin.Security.Jwt;
using GraphWebAPIDemo.App_Start;
using System.IdentityModel.Tokens;
namespace GraphWebApiDemo
{
public partial class Startup
{
public static string PublicClientId { get; private set; }
// 認証の構成の詳細については、https://go.microsoft.com/fwlink/?LinkId=301864 を参照してください
public void ConfigureAuth(IAppBuilder app)
{
var tokenValidationParameters = new TokenValidationParameters
{
ValidAudience = ConfigurationManager.AppSettings["ClientId"],
ValidIssuer = ConfigurationManager.AppSettings["Issuer"],
SaveSigninToken = true
};
// The more familiar UseWindowsAzureActiveDirectoryBearerAuthentication does not work
// with the Azure AD V2 endpoint, so use UseOAuthBearerAuthentication instead.
app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions
{
AccessTokenFormat = new JwtFormat(
tokenValidationParameters,
new OpenIdConnectCachingSecurityTokenProvider("https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration")
)
});
}
}
}
6. Web.Config の apPSettings に以下のキーを追加。ClientId は WebAPI アプリケーション ID、Issuer のテナント ID はアプリケーションを登録している Azure AD テナントの ID。
<appSettings>
<add key="ClientId" value="1bc3f11c-5074-407c-8c9c-8b2d20933db6"/>
<add key="Issuer" value="https://login.microsoftonline.com/00a88ff8-2bc8-4b65-bd69-c5979cd2b9b0/v2.0"/>
</appSettings>
※ Azure AD テナントの ID は Azure ポータル にログインし、「Azure Active Directory」選択後、プロパティから確認可能です。
WebAPI クライアントの開発
1. 次にクライアントアプリケーションの追加。ソリューションに新しくコンソールアプリケーションを追加。
2. NuGet の管理より「Microsoft.Identity.Client」、「JSON.NET」を追加。また参照の追加より System.Security アセンブリを追加。
3. TokenCacheHelper.cs を追加し、コードを以下と差し替え。
using Microsoft.Identity.Client;
using System.IO;
using System.Security.Cryptography;
namespace GraphWebAPIClientDemo
{
public partial class Program
{
static class TokenCacheHelper
{
/// <summary>
/// Get the user token cache
/// </summary>
/// <returns></returns>
public static TokenCache GetUserCache()
{
if (usertokenCache == null)
{
usertokenCache = new TokenCache();
usertokenCache.SetBeforeAccess(BeforeAccessNotification);
usertokenCache.SetAfterAccess(AfterAccessNotification);
}
return usertokenCache;
}
static TokenCache usertokenCache;
/// <summary>
/// Path to the token cache
/// </summary>
public static readonly string CacheFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalcache.bin";
private static readonly object FileLock = new object();
public static void BeforeAccessNotification(TokenCacheNotificationArgs args)
{
lock (FileLock)
{
args.TokenCache.Deserialize(File.Exists(CacheFilePath)
? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
null,
DataProtectionScope.CurrentUser)
: null);
}
}
public static void AfterAccessNotification(TokenCacheNotificationArgs args)
{
// if the access operation resulted in a cache update
if (args.TokenCache.HasStateChanged)
{
lock (FileLock)
{
// reflect changesgs in the persistent store
File.WriteAllBytes(CacheFilePath,
ProtectedData.Protect(args.TokenCache.Serialize(),
null,
DataProtectionScope.CurrentUser)
);
// once the write operationtakes place restore the HasStateChanged bit to filse
args.TokenCache.HasStateChanged = false;
}
}
}
}
}
}
4. Program.cs を以下のコードと差し替え。ClientId と scopes に指定しているリソースは、登録したアプリケーション ID と「{アプリケーション ID URI}/access_as_user」を指定。また apiServerUrl は WebAPI プロジェクトのプロパティより、Web にあるアドレスを指定。
using Microsoft.Identity.Client;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using System.Net.Http.Headers;
using Newtonsoft.Json;
namespace GraphWebAPIClientDemo
{
public partial class Program
{
private static string clientId = "550dcc01-129d-4b51-b80c-96995a6848e3";
private static PublicClientApplication pca;
private static string[] scopes = new string[] { "api://1bc3f11c-5074-407c-8c9c-8b2d20933db6/access_as_user" };
private static Uri apiServerUrl = new Uri("http://localhost:64133");
static void Main(string[] args)
{
#region GraphClient and Auth
pca = new PublicClientApplication(
clientId,
"https://login.microsoftonline.com/common/v2.0",
TokenCacheHelper.GetUserCache());
#endregion
Program p = new Program();
p.Run().Wait();
Console.ReadLine();
}
private async Task Run()
{
using (HttpClient client = new HttpClient())
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync(scopes));
client.BaseAddress = apiServerUrl;
var result = await client.GetAsync("/api/values");
if (result.IsSuccessStatusCode)
Console.WriteLine(JsonConvert.SerializeObject(
JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()),
Formatting.Indented));
}
}
private async Task<string> GetAccessTokenAsync(string[] scopes)
{
AuthenticationResult authResult = null;
try
{
authResult = await pca.AcquireTokenSilentAsync(scopes,
pca.Users.FirstOrDefault());
}
catch (MsalUiRequiredException ex)
{
authResult = await pca.AcquireTokenAsync(scopes, pca.Users.FirstOrDefault(), UIBehavior.Consent, null);
}
if (authResult != null)
return authResult.AccessToken;
else
return null;
}
}
}
動作確認
1. WebAPI プロジェクトを先に起動して、その後、クライアントアプリを実行。サインイン画面が出るので、「承諾をクリック」。
2. 認証が成功し、テンプレート通り値が返ることを確認。
これでまず Web API が Azure AD v2 でセキュアにできるところまで来ました。
コードの詳細を確認
この時点でのコードをまず見ていきます。
OAuth 2.0 認証
OWIN の UseOAuthBearerAuthentication を使って JWT フォーマットのトークンを利用するように指定。この際、TokenValidationParameters に対して、登録した Web API 向けに正しい発行機関が発行したトークンかを検証するよう指定しています。またアクセストークンのフォーマット形式として JwtFormat を指定し、パラメーターやメタデータ確認用のクラスを渡しています。
Azure AD v2 用のミドルウェアが出るともう少し奇麗に書けそうです。
var tokenValidationParameters = new TokenValidationParameters
{
ValidAudience = ConfigurationManager.AppSettings["ClientId"],
ValidIssuer = ConfigurationManager.AppSettings["Issuer"],
SaveSigninToken = true
};
// The more familiar UseWindowsAzureActiveDirectoryBearerAuthentication does not work
// with the Azure AD V2 endpoint, so use UseOAuthBearerAuthentication instead.
app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions
{
AccessTokenFormat = new JwtFormat(
tokenValidationParameters,
new OpenIdConnectCachingSecurityTokenProvider("https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration")
)
});
クライアントからの認証
WebAPI は Azure AD v2 エンドポイントに登録されているため、MSAL を利用できます。スコープは Microsoft Graph の時と異なり、「リソース/スコープ」のフォーマットで指定されます。
private static string[] scopes = new string[] { "api://1bc3f11c-5074-407c-8c9c-8b2d20933db6/access_as_user" };
Web API からユーザーの代理で Microsoft Graph にアクセス
ユーザーの認証が終わったので、次に Web API 側でユーザーの代わりにアクセストークンを取得するフローを追加します。
1. まず登録済アプリケーションでシークレットを取得。Microsoft アプリ登録ポータル にアクセスして、ログイン。WebAPI 用に登録したアプリケーションを開き、「アプリケーションシークレット」項目で「新しいパスワードを生成」をクリック。パスワードが表示されたらコピー。
2. プラットフォームの追加より Web を追加。
3. リダイレクト URL に WebAPI のアドレスを設定して、アプリケーションを保存。
4. Web.config の appSettings に取得したシークレット用のキーとリダイレクト URL 用を追加。
<add key="ClientSecret" value="xhkxxxxxxxxxxxxx"/>
<add key="RedirectUri" value="http://localhost:64133"/>
5. ValuesController.cs を以下のコードと差し替え。
using Microsoft.Identity.Client;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Configuration;
using System.IdentityModel.Tokens;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Http;
namespace GraphWebApiDemo.Controllers
{
[Authorize]
public class ValuesController : ApiController
{
// GET api/values
public async Task<IEnumerable<string>> Get()
{
string[] addinScopes = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value.Split(' ');
if (addinScopes.Contains("access_as_user"))
{
// Get the raw token that the add-in page received from the Office host.
var bootstrapContext = ClaimsPrincipal.Current.Identities.First().BootstrapContext
as BootstrapContext;
UserAssertion userAssertion = new UserAssertion(bootstrapContext.Token);
// Get the access token for MS Graph.
ClientCredential clientCred = new ClientCredential(ConfigurationManager.AppSettings["ClientSecret"]);
ConfidentialClientApplication cca =
new ConfidentialClientApplication(ConfigurationManager.AppSettings["ClientID"],
ConfigurationManager.AppSettings["RedirectUri"], clientCred, null, null);
string[] graphScopes = { "User.Read" };
AuthenticationResult result = await cca.AcquireTokenOnBehalfOfAsync(graphScopes, userAssertion, "https://login.microsoftonline.com/common/oauth2/v2.0");
using(HttpClient client = new HttpClient())
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken);
var responseMessage = await client.GetAsync("https://graph.microsoft.com/v1.0/me");
JToken me = JToken.Parse(await responseMessage.Content.ReadAsStringAsync());
List<string> results = new List<string>();
foreach(JProperty property in me.Children())
{
results.Add(property.Value.ToString());
}
return results;
}
}
return new string[] { "失敗", "Graph にアクセスできませんでした。" };
}
// GET api/values/5
public string Get(int id)
{
return "value";
}
// POST api/values
public void Post([FromBody]string value)
{
}
// PUT api/values/5
public void Put(int id, [FromBody]string value)
{
}
// DELETE api/values/5
public void Delete(int id)
{
}
}
}
動作確認
WebAPI プロジェクトを先に起動して、その後、クライアントアプリを実行。一度承認しているため、結果が返ります。
コードの詳細を確認
WebAPI としてのアクセストークン確認
access_as_user スコープで取得しているはずのトークンを再度確認しています。
string[] addinScopes = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value.Split(' ');
if (addinScopes.Contains("access_as_user"))
代理でアクセストークンを取得
代理取得のために、まず UserAssertion というユーザー情報を作ります。
var bootstrapContext = ClaimsPrincipal.Current.Identities.First().BootstrapContext
as BootstrapContext;
UserAssertion userAssertion = new UserAssertion(bootstrapContext.Token);
次に ConfidentialClientApplication を作成。
ClientCredential clientCred = new ClientCredential(ConfigurationManager.AppSettings["ClientSecret"]);
ConfidentialClientApplication cca =
new ConfidentialClientApplication(ConfigurationManager.AppSettings["ClientID"],
ConfigurationManager.AppSettings["RedirectUri"], clientCred, null, null);
最後にスコープを指定してトークンを取得。
string[] graphScopes = { "User.Read" };
AuthenticationResult result = await cca.AcquireTokenOnBehalfOfAsync(graphScopes, userAssertion, "https://login.microsoftonline.com/common/oauth2/v2.0");
まとめ
WebAPI あら Microsoft Graph を呼び出すシナリオは、上記のように WebAPI 自体のセキュア化と、代理フローを分けて考えると分かりやすいと思います。是非試してください。
参照
Azure Active Directory v2.0 と OAuth 2.0 の On-Behalf-Of フロー
GitHub: Calling a ASP.NET Core Web API from a WPF application using Azure AD V2
Building Web API Solutions with Authentication (英語動画)