ASP.NET Core で外部プロバイダーからの追加の要求とトークンを保持する

ASP.NET Core アプリで、Facebook、Google、Microsoft、Twitter などの外部認証プロバイダーからの追加の要求とトークンを確立できます。 プロバイダーがそのプラットフォーム上で公開しているユーザーの情報はそれぞれ異なりますが、ユーザー データを受け取って追加の要求に変換するパターンは同じです。

必須コンポーネント

アプリでサポートする外部認証プロバイダーを決定します。 各プロバイダーに対して、アプリを登録し、クライアント ID とクライアント シークレットを取得します。 詳細については、ASP.NET Core での Facebook 認証と Google 認証に関する記事を参照してください。 このサンプル アプリでは、Google 認証プロバイダーを使用します。

クライアント ID とクライアント シークレットを設定する

OAuth 認証プロバイダーでは、クライアント ID とクライアント シークレットを使用してアプリとの信頼関係を確立します。 クライアント ID とクライアント シークレットの値は、アプリをプロバイダーに登録するときに、外部認証プロバイダーによってそのアプリに対して作成されます。 アプリで使用する各外部プロバイダーを、プロバイダーのクライアント ID とクライアント シークレットを使用して個別に構成する必要があります。 詳細については、該当する外部認証プロバイダーのトピックを参照してください。

認証プロバイダーから ID またはアクセス トークンの形式で送信される省略可能な要求は、通常、プロバイダーのオンライン ポータルで構成します。 たとえば、Microsoft Azure Active Directory (AAD) では、アプリ登録の [トークン構成] ブレードで、省略可能な要求をアプリの ID トークンに割り当てることができます。 詳細については、方法: アプリに省略可能な要求を提供する (Azure ドキュメント) に関する記事をご覧ください。 その他のプロバイダーについては、それぞれの外部のドキュメント セットを参照してください。

このサンプル アプリでは、Google から提供されるクライアント ID とクライアント シークレットを使用して Google 認証プロバイダーを構成します。

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using WebGoogOauth.Data;

var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => 
                                  options.SignIn.RequireConfirmedAccount = true)
                                 .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();

var app = builder.Build();

// Remaining code removed for brevity.

認証スコープを確立する

Scope を指定して、プロバイダーから取得するアクセス許可の一覧を指定します。 一般的な外部プロバイダーの認証スコープを次の表に示します。

プロバイダー スコープ
Facebook https://www.facebook.com/dialog/oauth
Google profileemailopenid
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

このサンプル アプリでは、AuthenticationBuilderAddGoogle が呼び出されたときに、Google の profileemail、および openid スコープがフレームワークによって自動的に追加されます。 追加のスコープがアプリで必要な場合は、オプションに追加します。 次の例では、Google の https://www.googleapis.com/auth/user.birthday.read スコープがユーザーの誕生日を取得するために追加されます。

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

ユーザー データ キーをマップして要求を作成する

プロバイダーのオプションで、サインイン時に読み取るアプリ ID に対して、外部プロバイダーの JSON ユーザー データのキーまたはサブキーごとに、MapJsonKey または MapJsonSubKey を指定します。 要求の種類の詳細については、ClaimTypes を参照してください。

このサンプル アプリでは、Google ユーザー データの locale キーと picture キーから、ロケール (urn:google:locale) と画像 (urn:google:picture) の要求を作成します。

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync では、IdentityUser (ApplicationUser) が SignInAsync を使用してアプリにサインインしています。 サインイン プロセス中に、UserManager<TUser> は、Principal から使用可能なユーザー データに対する ApplicationUser 要求を格納できます。

このサンプル アプリでは、OnPostConfirmationAsync (Account/ExternalLogin.cshtml.cs) を使用して、サインインしている ApplicationUser のロケール (urn:google:locale) と画像 (urn:google:picture) の要求を GivenName の要求を含めて確立します。

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = CreateUser();

        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);
            if (result.Succeeded)
            {
                _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);

                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                // using Microsoft.AspNetCore.Authentication;
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = false;

                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                var callbackUrl = Url.Page(
                    "/Account/ConfirmEmail",
                    pageHandler: null,
                    values: new { area = "Identity", userId = userId, code = code },
                    protocol: Request.Scheme);

                await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                    $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (_userManager.Options.SignIn.RequireConfirmedAccount)
                {
                    return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                }

                await _signInManager.SignInAsync(user, props, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
        }
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    ProviderDisplayName = info.ProviderDisplayName;
    ReturnUrl = returnUrl;
    return Page();
}

既定では、ユーザーの要求は認証 cookie に格納されます。 次の理由で、認証 cookie が大きすぎると、アプリでエラーが発生する可能性があります。

  • cookie ヘッダーが長すぎることがブラウザーによって検出される。
  • 要求の全体的なサイズが大きすぎる。

ユーザーの要求を処理するために大量のユーザー データが必要な場合は、次のようにします。

  • 要求処理に対するユーザー要求の数とサイズを、アプリで必要とするものだけに制限します。
  • Cookie 認証ミドルウェアの SessionStore のカスタム ITicketStore を使用して、ID を要求全体で格納します。 大量の ID 情報をサーバーに保持しても、クライアントには小さいセッション識別子キーのみを送信します。

アクセス トークンを保存する

SaveTokens で、認可が成功した後に、アクセス トークンと更新トークンを AuthenticationProperties に格納するかどうかを定義します。 既定では、SaveTokens は、最終的な認証 cookie のサイズを小さくする false に設定されます。

このサンプル アプリでは、GoogleOptionsSaveTokens の値を true に設定します。

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

OnPostConfirmationAsync の実行時に、外部プロバイダーからのアクセス トークン (ExternalLoginInfo.AuthenticationTokens) を ApplicationUserAuthenticationProperties に格納します。

このサンプル アプリでは、アクセス トークンを Account/ExternalLogin.cshtml.csOnPostConfirmationAsync (新しいユーザー登録) と OnGetCallbackAsync (登録済みのユーザー) に保存します。

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information during confirmation.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = CreateUser();

        await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
        await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);

        var result = await _userManager.CreateAsync(user);
        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);
            if (result.Succeeded)
            {
                _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider);

                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user,
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                // using Microsoft.AspNetCore.Authentication;
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = false;

                var userId = await _userManager.GetUserIdAsync(user);
                var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
                code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
                var callbackUrl = Url.Page(
                    "/Account/ConfirmEmail",
                    pageHandler: null,
                    values: new { area = "Identity", userId = userId, code = code },
                    protocol: Request.Scheme);

                await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
                    $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");

                // If account confirmation is required, we need to show the link if we don't have a real email sender
                if (_userManager.Options.SignIn.RequireConfirmedAccount)
                {
                    return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
                }

                await _signInManager.SignInAsync(user, props, info.LoginProvider);
                return LocalRedirect(returnUrl);
            }
        }
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    ProviderDisplayName = info.ProviderDisplayName;
    ReturnUrl = returnUrl;
    return Page();
}

Note

サーバーサイド Blazor アプリの Razor コンポーネントにトークンを渡す方法については、「サーバーサイド ASP.NET Core Blazor のセキュリティに関するその他のシナリオ」を参照してください。

カスタム トークンを追加する方法

SaveTokens の一部として格納されるカスタム トークンを追加する方法を示すために、このサンプル アプリでは、AuthenticationToken.NameTicketCreated を設定し、現在の DateTime を持つ AuthenticationToken を追加します。

builder.Services.AddAuthentication().AddGoogle(googleOptions =>
{
    googleOptions.ClientId = configuration["Authentication:Google:ClientId"];
    googleOptions.ClientSecret = configuration["Authentication:Google:ClientSecret"];

    googleOptions.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    googleOptions.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");

    googleOptions.SaveTokens = true;

    googleOptions.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList();

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated",
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

要求を作成して追加する

フレームワークには、要求を作成してコレクションに追加するための一般的なアクションと拡張メソッドが用意されています。 詳細については、「 ClaimActionCollectionMapExtensions 」および「 ClaimActionCollectionUniqueExtensions」を参照してください。

ユーザーは、ClaimAction から派生し、Run 抽象メソッドを実装することで、カスタム アクションを定義できます。

詳細については、「Microsoft.AspNetCore.Authentication.OAuth.Claims」を参照してください。

ユーザー要求を追加して更新する

要求は、サインイン時ではなく、最初の登録時に外部プロバイダーからユーザー データベースにコピーされます。 ユーザーがアプリを使用するように登録した後、アプリで追加の要求が有効になっている場合は、ユーザーに対して SignInManager.RefreshSignInAsync を呼び出して、新しい認証 cookie を強制的に生成します。

開発環境でテスト ユーザー アカウントを使用している場合は、ユーザー アカウントを削除して再作成します。 実稼働システムの場合は、アプリに追加された新しい要求をユーザー アカウントにバックフィルすることができます。 ExternalLogin ページ Areas/Pages/Identity/Account/Manage のアプリにスキャフォールディングした後、ExternalLogin.cshtml.cs ファイルの ExternalLoginModel に次のコードを追加します。

追加される要求の辞書を追加します。 辞書キーを使用して要求の種類を保持し、値を使用して既定値を保持します。 クラスの先頭に次のコード行を追加します。 次の例では、ユーザーの Google 画像に対する要求が 1 つ追加され、一般的な顔写真画像が既定値として設定されているものとします。

private readonly IReadOnlyDictionary<string, string> _claimsToSync =
     new Dictionary<string, string>()
     {
             { "urn:google:picture", "https://localhost:5001/headshot.png" },
     };

OnGetCallbackAsync メソッドの既定のコードを次のコードに置き換えます。 このコードを使用して、要求辞書をループ処理します。 要求は、ユーザーごとに追加 (バックフィル) または更新されます。 要求が追加または更新されると、ユーザーのサインインが SignInManager<TUser> を使用し更新され、既存の認証プロパティ (AuthenticationProperties) が保持されます。

private readonly IReadOnlyDictionary<string, string> _claimsToSync =
     new Dictionary<string, string>()
     {
             { "urn:google:picture", "https://localhost:5001/headshot.png" },
     };

public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    if (remoteError != null)
    {
        ErrorMessage = $"Error from external provider: {remoteError}";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
        ErrorMessage = "Error loading external login information.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    // Sign in the user with this external login provider if the user already has a login.
    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
    if (result.Succeeded)
    {
        _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
        if (_claimsToSync.Count > 0)
        {
            var user = await _userManager.FindByLoginAsync(info.LoginProvider,
                info.ProviderKey);
            var userClaims = await _userManager.GetClaimsAsync(user);
            bool refreshSignIn = false;

            foreach (var addedClaim in _claimsToSync)
            {
                var userClaim = userClaims
                    .FirstOrDefault(c => c.Type == addedClaim.Key);

                if (info.Principal.HasClaim(c => c.Type == addedClaim.Key))
                {
                    var externalClaim = info.Principal.FindFirst(addedClaim.Key);

                    if (userClaim == null)
                    {
                        await _userManager.AddClaimAsync(user,
                            new Claim(addedClaim.Key, externalClaim.Value));
                        refreshSignIn = true;
                    }
                    else if (userClaim.Value != externalClaim.Value)
                    {
                        await _userManager
                            .ReplaceClaimAsync(user, userClaim, externalClaim);
                        refreshSignIn = true;
                    }
                }
                else if (userClaim == null)
                {
                    // Fill with a default value
                    await _userManager.AddClaimAsync(user, new Claim(addedClaim.Key,
                        addedClaim.Value));
                    refreshSignIn = true;
                }
            }

            if (refreshSignIn)
            {
                await _signInManager.RefreshSignInAsync(user);
            }
        }

        return LocalRedirect(returnUrl);
    }
    if (result.IsLockedOut)
    {
        return RedirectToPage("./Lockout");
    }
    else
    {
        // If the user does not have an account, then ask the user to create an account.
        ReturnUrl = returnUrl;
        ProviderDisplayName = info.ProviderDisplayName;
        if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input = new InputModel
            {
                Email = info.Principal.FindFirstValue(ClaimTypes.Email)
            };
        }
        return Page();
    }
}

ユーザーがサインインしている間に要求が変更された場合にも同様のアプローチが行われますが、バックフィル ステップは必要ありません。 ユーザーの要求を更新するには、ユーザーに対して以下を呼び出します。

要求アクションと要求を削除する

ClaimActionCollection.Remove(String) を使用して、特定の ClaimType に対するすべての要求アクションをコレクションから削除します。 ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String) を使用して、特定の ClaimType の要求を ID から削除します。 DeleteClaim は、主に OpenID Connect (OIDC) と共に使用し、プロトコルによって生成された要求を削除します。

サンプル アプリの出力

サンプル アプリを実行し、MyClaims リンクを選択します。

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

プロキシまたはロード バランサーによる要求情報の転送

アプリがプロキシ サーバーまたはロード バランサーの背後に展開されると、元の要求情報の一部が要求ヘッダー内でアプリに転送される場合があります。 通常、この情報にはセキュアな要求スキーム (https)、ホスト、およびクライアント IP アドレスが含まれます。 アプリでは、これらの要求ヘッダーを自動的に読み取って、元の要求情報を検出して使用することはありません。

スキームは、外部プロバイダーによる認証フローに影響を及ぼすリンクの生成に使用されます。 セキュアなスキーム (https) が失われると、アプリでは、安全ではない不正なリダイレクト URL が生成されます。

Forwarded Headers Middleware を使用して、アプリが要求を処理する際に元の要求情報を利用できるようにします。

詳細については、「プロキシ サーバーとロード バランサーを使用するために ASP.NET Core を構成する」を参照してください。

サンプル コードを表示またはダウンロードします (ダウンロード方法)。

ASP.NET Core アプリで、Facebook、Google、Microsoft、Twitter などの外部認証プロバイダーからの追加の要求とトークンを確立できます。 プロバイダーがそのプラットフォーム上で公開しているユーザーの情報はそれぞれ異なりますが、ユーザー データを受け取って追加の要求に変換するパターンは同じです。

サンプル コードを表示またはダウンロードします (ダウンロード方法)。

必須コンポーネント

アプリでサポートする外部認証プロバイダーを決定します。 各プロバイダーに対して、アプリを登録し、クライアント ID とクライアント シークレットを取得します。 詳細については、ASP.NET Core での Facebook 認証と Google 認証に関する記事を参照してください。 このサンプル アプリでは、Google 認証プロバイダーを使用します。

クライアント ID とクライアント シークレットを設定する

OAuth 認証プロバイダーでは、クライアント ID とクライアント シークレットを使用してアプリとの信頼関係を確立します。 クライアント ID とクライアント シークレットの値は、アプリをプロバイダーに登録するときに、外部認証プロバイダーによってそのアプリに対して作成されます。 アプリで使用する各外部プロバイダーを、プロバイダーのクライアント ID とクライアント シークレットを使用して個別に構成する必要があります。 詳細については、ご自分のシナリオに適用する外部認証プロバイダーのトピックを参照してください。

認証プロバイダーから ID またはアクセス トークンの形式で送信される省略可能な要求は、通常、プロバイダーのオンライン ポータルで構成します。 たとえば、Microsoft Azure Active Directory (AAD) では、アプリ登録の [トークン構成] ブレードで、省略可能な要求をアプリの ID トークンに割り当てることができます。 詳細については、方法: アプリに省略可能な要求を提供する (Azure ドキュメント) に関する記事をご覧ください。 その他のプロバイダーについては、それぞれの外部のドキュメント セットを参照してください。

このサンプル アプリでは、Google から提供されるクライアント ID とクライアント シークレットを使用して Google 認証プロバイダーを構成します。

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

認証スコープを確立する

Scope を指定して、プロバイダーから取得するアクセス許可の一覧を指定します。 一般的な外部プロバイダーの認証スコープを次の表に示します。

プロバイダー スコープ
Facebook https://www.facebook.com/dialog/oauth
Google profileemailopenid
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

このサンプル アプリでは、AuthenticationBuilderAddGoogle が呼び出されたときに、Google の profileemail、および openid スコープがフレームワークによって自動的に追加されます。 追加のスコープがアプリで必要な場合は、オプションに追加します。 次の例では、Google の https://www.googleapis.com/auth/user.birthday.read スコープがユーザーの誕生日を取得するために追加されます。

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

ユーザー データ キーをマップして要求を作成する

プロバイダーのオプションで、サインイン時に読み取るアプリ ID に対して、外部プロバイダーの JSON ユーザー データのキー/サブキーごとに、MapJsonKey または MapJsonSubKey を指定します。 要求の種類の詳細については、ClaimTypes を参照してください。

このサンプル アプリでは、Google ユーザー データの locale キーと picture キーから、ロケール (urn:google:locale) と画像 (urn:google:picture) の要求を作成します。

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync では、IdentityUser (ApplicationUser) が SignInAsync を使用してアプリにサインインしています。 サインイン プロセス中に、UserManager<TUser> は、Principal から使用可能なユーザー データに対する ApplicationUser 要求を格納できます。

このサンプル アプリでは、OnPostConfirmationAsync (Account/ExternalLogin.cshtml.cs) を使用して、サインインしている ApplicationUser のロケール (urn:google:locale) と画像 (urn:google:picture) の要求を GivenName の要求を含めて確立します。

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

既定では、ユーザーの要求は認証 cookie に格納されます。 次の理由で、認証 cookie が大きすぎると、アプリでエラーが発生する可能性があります。

  • cookie ヘッダーが長すぎることがブラウザーによって検出される。
  • 要求の全体的なサイズが大きすぎる。

ユーザーの要求を処理するために大量のユーザー データが必要な場合は、次のようにします。

  • 要求処理に対するユーザー要求の数とサイズを、アプリで必要とするものだけに制限します。
  • Cookie 認証ミドルウェアの SessionStore のカスタム ITicketStore を使用して、ID を要求全体で格納します。 大量の ID 情報をサーバーに保持しても、クライアントには小さいセッション識別子キーのみを送信します。

アクセス トークンを保存する

SaveTokens で、認可が成功した後に、アクセス トークンと更新トークンを AuthenticationProperties に格納するかどうかを定義します。 既定では、SaveTokens は、最終的な認証 cookie のサイズを小さくする false に設定されます。

このサンプル アプリでは、GoogleOptionsSaveTokens の値を true に設定します。

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

OnPostConfirmationAsync の実行時に、外部プロバイダーからのアクセス トークン (ExternalLoginInfo.AuthenticationTokens) を ApplicationUserAuthenticationProperties に格納します。

このサンプル アプリでは、アクセス トークンを Account/ExternalLogin.cshtml.csOnPostConfirmationAsync (新しいユーザー登録) と OnGetCallbackAsync (登録済みのユーザー) に保存します。

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

Note

サーバーサイド Blazor アプリの Razor コンポーネントにトークンを渡す方法については、「サーバーサイド ASP.NET Core Blazor のセキュリティに関するその他のシナリオ」を参照してください。

カスタム トークンを追加する方法

SaveTokens の一部として格納されるカスタム トークンを追加する方法を示すために、このサンプル アプリでは、AuthenticationToken.NameTicketCreated を設定し、現在の DateTime を持つ AuthenticationToken を追加します。

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

要求を作成して追加する

フレームワークには、要求を作成してコレクションに追加するための一般的なアクションと拡張メソッドが用意されています。 詳細については、「 ClaimActionCollectionMapExtensions 」および「 ClaimActionCollectionUniqueExtensions」を参照してください。

ユーザーは、ClaimAction から派生し、Run 抽象メソッドを実装することで、カスタム アクションを定義できます。

詳細については、「Microsoft.AspNetCore.Authentication.OAuth.Claims」を参照してください。

ユーザー要求を追加して更新する

要求は、サインイン時ではなく、最初の登録時に外部プロバイダーからユーザー データベースにコピーされます。 ユーザーがアプリを使用するように登録した後、アプリで追加の要求が有効になっている場合は、ユーザーに対して SignInManager.RefreshSignInAsync を呼び出して、新しい認証 cookie を強制的に生成します。

開発環境でテスト ユーザー アカウントを使用している場合は、ただ単にユーザー アカウントを削除し、再作成することができます。 実稼働システムの場合は、アプリに追加された新しい要求をユーザー アカウントにバックフィルすることができます。 ExternalLogin ページ Areas/Pages/Identity/Account/Manage のアプリにスキャフォールディングした後、ExternalLogin.cshtml.cs ファイルの ExternalLoginModel に次のコードを追加します。

追加される要求の辞書を追加します。 辞書キーを使用して要求の種類を保持し、値を使用して既定値を保持します。 クラスの先頭に次のコード行を追加します。 次の例では、ユーザーの Google 画像に対する要求が 1 つ追加され、一般的な顔写真画像が既定値として設定されているものとします。

private readonly IReadOnlyDictionary<string, string> _claimsToSync = 
    new Dictionary<string, string>()
    {
        { "urn:google:picture", "https://localhost:5001/headshot.png" },
    };

OnGetCallbackAsync メソッドの既定のコードを次のコードに置き換えます。 このコードを使用して、要求辞書をループ処理します。 要求は、ユーザーごとに追加 (バックフィル) または更新されます。 要求が追加または更新されると、ユーザーのサインインが SignInManager<TUser> を使用し更新され、既存の認証プロパティ (AuthenticationProperties) が保持されます。

public async Task<IActionResult> OnGetCallbackAsync(
    string returnUrl = null, string remoteError = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");

    if (remoteError != null)
    {
        ErrorMessage = $"Error from external provider: {remoteError}";

        return RedirectToPage("./Login", new {ReturnUrl = returnUrl });
    }

    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = "Error loading external login information.";
        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    // Sign in the user with this external login provider if the user already has a 
    // login.
    var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, 
        info.ProviderKey, isPersistent: false, bypassTwoFactor : true);

    if (result.Succeeded)
    {
        _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", 
            info.Principal.Identity.Name, info.LoginProvider);

        if (_claimsToSync.Count > 0)
        {
            var user = await _userManager.FindByLoginAsync(info.LoginProvider, 
                info.ProviderKey);
            var userClaims = await _userManager.GetClaimsAsync(user);
            bool refreshSignIn = false;

            foreach (var addedClaim in _claimsToSync)
            {
                var userClaim = userClaims
                    .FirstOrDefault(c => c.Type == addedClaim.Key);

                if (info.Principal.HasClaim(c => c.Type == addedClaim.Key))
                {
                    var externalClaim = info.Principal.FindFirst(addedClaim.Key);

                    if (userClaim == null)
                    {
                        await _userManager.AddClaimAsync(user, 
                            new Claim(addedClaim.Key, externalClaim.Value));
                        refreshSignIn = true;
                    }
                    else if (userClaim.Value != externalClaim.Value)
                    {
                        await _userManager
                            .ReplaceClaimAsync(user, userClaim, externalClaim);
                        refreshSignIn = true;
                    }
                }
                else if (userClaim == null)
                {
                    // Fill with a default value
                    await _userManager.AddClaimAsync(user, new Claim(addedClaim.Key, 
                        addedClaim.Value));
                    refreshSignIn = true;
                }
            }

            if (refreshSignIn)
            {
                await _signInManager.RefreshSignInAsync(user);
            }
        }

        return LocalRedirect(returnUrl);
    }

    if (result.IsLockedOut)
    {
        return RedirectToPage("./Lockout");
    }
    else
    {
        // If the user does not have an account, then ask the user to create an 
        // account.
        ReturnUrl = returnUrl;
        ProviderDisplayName = info.ProviderDisplayName;

        if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
        {
            Input = new InputModel
            {
                Email = info.Principal.FindFirstValue(ClaimTypes.Email)
            };
        }

        return Page();
    }
}

ユーザーがサインインしている間に要求が変更された場合にも同様のアプローチが行われますが、バックフィル ステップは必要ありません。 ユーザーの要求を更新するには、ユーザーに対して以下を呼び出します。

要求アクションと要求の削除

ClaimActionCollection.Remove(String) を使用して、特定の ClaimType に対するすべての要求アクションをコレクションから削除します。 ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String) を使用して、特定の ClaimType の要求を ID から削除します。 DeleteClaim は、主に OpenID Connect (OIDC) と共に使用し、プロトコルによって生成された要求を削除します。

サンプル アプリの出力

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

プロキシまたはロード バランサーによる要求情報の転送

アプリがプロキシ サーバーまたはロード バランサーの背後に展開されると、元の要求情報の一部が要求ヘッダー内でアプリに転送される場合があります。 通常、この情報にはセキュアな要求スキーム (https)、ホスト、およびクライアント IP アドレスが含まれます。 アプリでは、これらの要求ヘッダーを自動的に読み取って、元の要求情報を検出して使用することはありません。

スキームは、外部プロバイダーによる認証フローに影響を及ぼすリンクの生成に使用されます。 セキュアなスキーム (https) が失われると、アプリでは、安全ではない不正なリダイレクト URL が生成されます。

Forwarded Headers Middleware を使用して、アプリが要求を処理する際に元の要求情報を利用できるようにします。

詳細については、「プロキシ サーバーとロード バランサーを使用するために ASP.NET Core を構成する」を参照してください。

ASP.NET Core アプリで、Facebook、Google、Microsoft、Twitter などの外部認証プロバイダーからの追加の要求とトークンを確立できます。 プロバイダーがそのプラットフォーム上で公開しているユーザーの情報はそれぞれ異なりますが、ユーザー データを受け取って追加の要求に変換するパターンは同じです。

サンプル コードを表示またはダウンロードします (ダウンロード方法)。

必須コンポーネント

アプリでサポートする外部認証プロバイダーを決定します。 各プロバイダーに対して、アプリを登録し、クライアント ID とクライアント シークレットを取得します。 詳細については、ASP.NET Core での Facebook 認証と Google 認証に関する記事を参照してください。 このサンプル アプリでは、Google 認証プロバイダーを使用します。

クライアント ID とクライアント シークレットを設定する

OAuth 認証プロバイダーでは、クライアント ID とクライアント シークレットを使用してアプリとの信頼関係を確立します。 クライアント ID とクライアント シークレットの値は、アプリをプロバイダーに登録するときに、外部認証プロバイダーによってそのアプリに対して作成されます。 アプリで使用する各外部プロバイダーを、プロバイダーのクライアント ID とクライアント シークレットを使用して個別に構成する必要があります。 詳細については、ご自分のシナリオに適用する外部認証プロバイダーのトピックを参照してください。

このサンプル アプリでは、Google から提供されるクライアント ID とクライアント シークレットを使用して Google 認証プロバイダーを構成します。

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

認証スコープを確立する

Scope を指定して、プロバイダーから取得するアクセス許可の一覧を指定します。 一般的な外部プロバイダーの認証スコープを次の表に示します。

プロバイダー スコープ
Facebook https://www.facebook.com/dialog/oauth
Google https://www.googleapis.com/auth/userinfo.profile
Microsoft https://login.microsoftonline.com/common/oauth2/v2.0/authorize
Twitter https://api.twitter.com/oauth/authenticate

このサンプル アプリでは、AuthenticationBuilderAddGoogle が呼び出されたときに、Google の userinfo.profile スコープがフレームワークによって自動的に追加されます。 追加のスコープがアプリで必要な場合は、オプションに追加します。 次の例では、Google の https://www.googleapis.com/auth/user.birthday.read スコープがユーザーの誕生日を取得するために追加されます。

options.Scope.Add("https://www.googleapis.com/auth/user.birthday.read");

ユーザー データ キーをマップして要求を作成する

プロバイダーのオプションで、サインイン時に読み取るアプリ ID に対して、外部プロバイダーの JSON ユーザー データのキー/サブキーごとに、MapJsonKey または MapJsonSubKey を指定します。 要求の種類の詳細については、ClaimTypes を参照してください。

このサンプル アプリでは、Google ユーザー データの locale キーと picture キーから、ロケール (urn:google:locale) と画像 (urn:google:picture) の要求を作成します。

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

Microsoft.AspNetCore.Identity.UI.Pages.Account.Internal.ExternalLoginModel.OnPostConfirmationAsync では、IdentityUser (ApplicationUser) が SignInAsync を使用してアプリにサインインしています。 サインイン プロセス中に、UserManager<TUser> は、Principal から使用可能なユーザー データに対する ApplicationUser 要求を格納できます。

このサンプル アプリでは、OnPostConfirmationAsync (Account/ExternalLogin.cshtml.cs) を使用して、サインインしている ApplicationUser のロケール (urn:google:locale) と画像 (urn:google:picture) の要求を GivenName の要求を含めて確立します。

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

既定では、ユーザーの要求は認証 cookie に格納されます。 次の理由で、認証 cookie が大きすぎると、アプリでエラーが発生する可能性があります。

  • cookie ヘッダーが長すぎることがブラウザーによって検出される。
  • 要求の全体的なサイズが大きすぎる。

ユーザーの要求を処理するために大量のユーザー データが必要な場合は、次のようにします。

  • 要求処理に対するユーザー要求の数とサイズを、アプリで必要とするものだけに制限します。
  • Cookie 認証ミドルウェアの SessionStore のカスタム ITicketStore を使用して、ID を要求全体で格納します。 大量の ID 情報をサーバーに保持しても、クライアントには小さいセッション識別子キーのみを送信します。

アクセス トークンを保存する

SaveTokens で、認可が成功した後に、アクセス トークンと更新トークンを AuthenticationProperties に格納するかどうかを定義します。 既定では、SaveTokens は、最終的な認証 cookie のサイズを小さくする false に設定されます。

このサンプル アプリでは、GoogleOptionsSaveTokens の値を true に設定します。

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

OnPostConfirmationAsync の実行時に、外部プロバイダーからのアクセス トークン (ExternalLoginInfo.AuthenticationTokens) を ApplicationUserAuthenticationProperties に格納します。

このサンプル アプリでは、アクセス トークンを Account/ExternalLogin.cshtml.csOnPostConfirmationAsync (新しいユーザー登録) と OnGetCallbackAsync (登録済みのユーザー) に保存します。

public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
    returnUrl = returnUrl ?? Url.Content("~/");
    // Get the information about the user from the external login provider
    var info = await _signInManager.GetExternalLoginInfoAsync();

    if (info == null)
    {
        ErrorMessage = 
            "Error loading external login information during confirmation.";

        return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
    }

    if (ModelState.IsValid)
    {
        var user = new IdentityUser
        {
            UserName = Input.Email, 
            Email = Input.Email 
        };

        var result = await _userManager.CreateAsync(user);

        if (result.Succeeded)
        {
            result = await _userManager.AddLoginAsync(user, info);

            if (result.Succeeded)
            {
                // If they exist, add claims to the user for:
                //    Given (first) name
                //    Locale
                //    Picture
                if (info.Principal.HasClaim(c => c.Type == ClaimTypes.GivenName))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst(ClaimTypes.GivenName));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:locale"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:locale"));
                }

                if (info.Principal.HasClaim(c => c.Type == "urn:google:picture"))
                {
                    await _userManager.AddClaimAsync(user, 
                        info.Principal.FindFirst("urn:google:picture"));
                }

                // Include the access token in the properties
                var props = new AuthenticationProperties();
                props.StoreTokens(info.AuthenticationTokens);
                props.IsPersistent = true;

                await _signInManager.SignInAsync(user, props);

                _logger.LogInformation(
                    "User created an account using {Name} provider.", 
                    info.LoginProvider);

                return LocalRedirect(returnUrl);
            }
        }

        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    LoginProvider = info.LoginProvider;
    ReturnUrl = returnUrl;
    return Page();
}

カスタム トークンを追加する方法

SaveTokens の一部として格納されるカスタム トークンを追加する方法を示すために、このサンプル アプリでは、AuthenticationToken.NameTicketCreated を設定し、現在の DateTime を持つ AuthenticationToken を追加します。

services.AddAuthentication().AddGoogle(options =>
{
    // Provide the Google Client ID
    options.ClientId = "XXXXXXXXXXXXXXX.apps.googleusercontent.com";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientId" "{Client ID}"

    // Provide the Google Client Secret
    options.ClientSecret = "{Client Secret}";
    // Register with User Secrets using:
    // dotnet user-secrets set "Authentication:Google:ClientSecret" "{Client Secret}"

    options.ClaimActions.MapJsonKey("urn:google:picture", "picture", "url");
    options.ClaimActions.MapJsonKey("urn:google:locale", "locale", "string");
    options.SaveTokens = true;

    options.Events.OnCreatingTicket = ctx =>
    {
        List<AuthenticationToken> tokens = ctx.Properties.GetTokens().ToList(); 

        tokens.Add(new AuthenticationToken()
        {
            Name = "TicketCreated", 
            Value = DateTime.UtcNow.ToString()
        });

        ctx.Properties.StoreTokens(tokens);

        return Task.CompletedTask;
    };
});

要求を作成して追加する

フレームワークには、要求を作成してコレクションに追加するための一般的なアクションと拡張メソッドが用意されています。 詳細については、「 ClaimActionCollectionMapExtensions 」および「 ClaimActionCollectionUniqueExtensions」を参照してください。

ユーザーは、ClaimAction から派生し、Run 抽象メソッドを実装することで、カスタム アクションを定義できます。

詳細については、「Microsoft.AspNetCore.Authentication.OAuth.Claims」を参照してください。

要求アクションと要求の削除

ClaimActionCollection.Remove(String) を使用して、特定の ClaimType に対するすべての要求アクションをコレクションから削除します。 ClaimActionCollectionMapExtensions.DeleteClaim(ClaimActionCollection, String) を使用して、特定の ClaimType の要求を ID から削除します。 DeleteClaim は、主に OpenID Connect (OIDC) と共に使用し、プロトコルによって生成された要求を削除します。

サンプル アプリの出力

User Claims

http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier
    9b342344f-7aab-43c2-1ac1-ba75912ca999
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
    someone@gmail.com
AspNet.Identity.SecurityStamp
    7D4312MOWRYYBFI1KXRPHGOSTBVWSFDE
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
    Judy
urn:google:locale
    en
urn:google:picture
    https://lh4.googleusercontent.com/-XXXXXX/XXXXXX/XXXXXX/XXXXXX/photo.jpg

Authentication Properties

.Token.access_token
    yc23.AlvoZqz56...1lxltXV7D-ZWP9
.Token.token_type
    Bearer
.Token.expires_at
    2019-04-11T22:14:51.0000000+00:00
.Token.TicketCreated
    4/11/2019 9:14:52 PM
.TokenNames
    access_token;token_type;expires_at;TicketCreated
.persistent
.issued
    Thu, 11 Apr 2019 20:51:06 GMT
.expires
    Thu, 25 Apr 2019 20:51:06 GMT

プロキシまたはロード バランサーによる要求情報の転送

アプリがプロキシ サーバーまたはロード バランサーの背後に展開されると、元の要求情報の一部が要求ヘッダー内でアプリに転送される場合があります。 通常、この情報にはセキュアな要求スキーム (https)、ホスト、およびクライアント IP アドレスが含まれます。 アプリでは、これらの要求ヘッダーを自動的に読み取って、元の要求情報を検出して使用することはありません。

スキームは、外部プロバイダーによる認証フローに影響を及ぼすリンクの生成に使用されます。 セキュアなスキーム (https) が失われると、アプリでは、安全ではない不正なリダイレクト URL が生成されます。

Forwarded Headers Middleware を使用して、アプリが要求を処理する際に元の要求情報を利用できるようにします。

詳細については、「プロキシ サーバーとロード バランサーを使用するために ASP.NET Core を構成する」を参照してください。

その他の技術情報

  • dotnet/AspNetCore エンジニアリング SocialSample アプリ: リンク先のサンプル アプリは、dotnet/AspNetCore GitHub リポジトリの main エンジニアリング ブランチにあります。 main ブランチには、ASP.NET Core の次のリリースに向けたアクティブな開発のコードが含まれています。 リリース済みバージョンの ASP.NET Core についてサンプル アプリのバージョンを確認するには、 [Branch](ブランチ) ドロップ ダウン リストを使用してリリース ブランチ (例: release/{X.Y}) を選択します。