ASP.NET Core アプリにサインインを追加する

このクイックスタートでは、ASP.NET Core の標準 OpenID Connect クライアントである Microsoft.AspNetCore.Authentication.OpenIdConnect を使って、ASP.NET Core 10 の Razor Pages アプリに KeenAuth のサインインを追加します。KeenAuth にはまだ .NET SDK がなく、必要もありません。KeenAuth は標準の OpenID Connect プロバイダです。お使いのアプリは OAuth クライアントとして動作し、すべてのトークンをサーバー側で保持します。ブラウザが受け取るのは、ランダムなセッションキーを含む Cookie だけです。サインインを設定し、ページを保護し、ユーザーのアクセストークンで保護された API を呼び出し、サインアウトして、アプリをオフラインでテストします。

このアプリには次のルートがあります。

ルート 役割
GET / 公開のホームページ。サインインのリンク、または利用者の名前とサインアウトボタンを表示します
GET /login サインインを開始します。KeenAuth にリダイレクトします
GET /signin-oidc OpenID Connect ハンドラーが提供するコールバックです
GET /dashboard セッションが必要です。利用者と、API が返すメッセージを表示します
POST /logout ここと KeenAuth の両方でセッションを終了します。アンチフォージェリトークンが必要です

始める前に

1. パッケージを追加する

このアプリのパッケージは 1 つで、正確なバージョンに固定されています。ロックファイル(packages.lock.json。RestorePackagesWithLockFile で有効にします)が、ほかのすべてのパッケージをコンテンツハッシュ付きで固定します。

    <!-- The standard OpenID Connect client of ASP.NET Core (MIT). Exact version; the lock file pins the rest. -->
    <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="[10.0.14]" />

ロックモードで復元します。依存関係グラフが変わった場合は、黙って通過させず失敗させるためです。

dotnet restore --locked-mode

2. 設定する

このアプリは設定を環境変数からのみ読み込みます。その値をコミットしないでください。

変数 必須 意味
KEENAUTH_ISSUER はい 環境の発行者。https
KEENAUTH_CLIENT_ID はい Web アプリケーションの client_id
KEENAUTH_CLIENT_SECRET はい そのクライアントシークレット
APP_BASE_URL はい アプリの公開 URL。リダイレクト URI はこれから組み立てます。http は localhost、127.0.0.1、[::1] の場合のみ
API_URL はい アプリが呼び出す API。たとえば http://localhost:3001
KEENAUTH_API_AUDIENCE はい KeenAuth における、その API の識別子
SESSION_SECURE_COOKIE いいえ 単純な http://localhost の場合のみ false

KeenAuthSettings.From は、起動時にこれらの値を読み込み、検証します。欠けている値や使えない値があるとアプリは停止し、どのエラーメッセージにも値は含まれません。

    public static KeenAuthSettings From(Func<string, string?> env) => new(
        Url(Need(env, "KEENAUTH_ISSUER"), "KEENAUTH_ISSUER", httpsOnly: true, anyScheme: false),
        Need(env, "KEENAUTH_CLIENT_ID"),
        Need(env, "KEENAUTH_CLIENT_SECRET"),
        Url(Need(env, "APP_BASE_URL"), "APP_BASE_URL", httpsOnly: false, anyScheme: false),
        Url(Need(env, "API_URL"), "API_URL", httpsOnly: false, anyScheme: false),
        // RFC 8707: the access token is requested for your API, which then accepts it as its audience.
        Url(Need(env, "KEENAUTH_API_AUDIENCE"), "KEENAUTH_API_AUDIENCE", httpsOnly: false, anyScheme: true),
        // The session cookie is Secure unless this is explicitly "false" (plain http://localhost).
        !string.Equals(env("SESSION_SECURE_COOKIE"), "false", StringComparison.OrdinalIgnoreCase));

3. セッションをサーバー側に保持する

Cookie スキームが、利用者をお使いのアプリにサインインさせます。その Cookie が持つのはランダムなキーだけで、利用者情報とトークンはサーバー上のチケットストア(1 つのプロセスのメモリ内にある MemoryTicketStore)に保持されます。

            .AddCookie(options =>
            {
                // The cookie holds only a random key; the user and the tokens stay in MemoryTicketStore.
                options.Cookie.HttpOnly = true;
                options.Cookie.SameSite = SameSiteMode.Lax;
                options.Cookie.SecurePolicy = settings.SecureCookie ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
                options.ExpireTimeSpan = TimeSpan.FromHours(8);
                options.SlidingExpiration = false;
            })

4. OpenID Connect ハンドラーを設定する

ハンドラーは、環境のディスカバリ(<issuer>/.well-known/openid-configuration)と署名鍵を読み込みます。認可コードフローを PKCE(S256)、state、nonce とともに使用し、戻ってくるときにそれらを検証します。ディスカバリがプッシュ認可リクエストのエンドポイントを示している場合は、ハンドラーがそれを使用します(RFC 9126)。

                // The endpoints and the signing keys come from discovery: <issuer>/.well-known/openid-configuration.
                options.Authority = settings.Issuer;
                options.ClientId = settings.ClientId;
                options.ClientSecret = settings.ClientSecret;
                options.ResponseType = OpenIdConnectResponseType.Code;
                options.ResponseMode = OpenIdConnectResponseMode.Query;
                // PKCE (RFC 7636) with S256; the handler also sends state and nonce and checks them on the way back.
                options.UsePkce = true;
                options.Scope.Clear();
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Scope.Add("email");
                // messages:read is a scope of your API; the example API requires it on /api/messages.
                options.Scope.Add("messages:read");
                options.SaveTokens = true;
                options.MapInboundClaims = false;
                options.GetClaimsFromUserInfoEndpoint = false;
                // Pushed authorization requests (RFC 9126) when the environment offers the endpoint.
                options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.UseIfAvailable;
                options.CallbackPath = "/signin-oidc";

                // ID tokens must be signed, with the keys of the environment and one of these algorithms.
                options.TokenValidationParameters.RequireSignedTokens = true;
                options.TokenValidationParameters.ValidateIssuerSigningKey = true;
                options.TokenValidationParameters.ValidAlgorithms = ["RS256", "ES256", "PS256"];
                options.TokenValidationParameters.NameClaimType = "name";

                // These cookies carry the state and the nonce between the redirect and the callback.
                var cookiePolicy = settings.SecureCookie ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest;
                options.NonceCookie.SameSite = SameSiteMode.Lax;
                options.NonceCookie.SecurePolicy = cookiePolicy;
                options.CorrelationCookie.SameSite = SameSiteMode.Lax;
                options.CorrelationCookie.SecurePolicy = cookiePolicy;

3 つのイベントがハンドラーを完成させます。1 つ目は、お使いの API の識別子を resource(RFC 8707)として追加します。これがないと、アクセストークンはお使いの API 向けにならず、API はそれを拒否します。2 つ目は、認可レスポンスの iss パラメータを検証します(RFC 9207)。ASP.NET Core はこれを検証せず、KeenAuth はこれを公開しています。

                    OnRedirectToIdentityProvider = context =>
                    {
                        // Build the redirect URI from the setting, not from the Host header of the request.
                        context.ProtocolMessage.RedirectUri = settings.RedirectUri;
                        // RFC 8707: an access token for your API.
                        context.ProtocolMessage.SetParameter("resource", settings.ApiAudience);
                        return Task.CompletedTask;
                    },
                    OnMessageReceived = async context =>
                    {
                        // RFC 9207: the authorization response must name the issuer that answered.
                        var configuration = await context.Options.ConfigurationManager!
                            .GetConfigurationAsync(context.HttpContext.RequestAborted);
                        var iss = context.ProtocolMessage.Iss;
                        if (iss is not null ? iss != configuration.Issuer : configuration.AuthorizationResponseIssParameterSupported)
                        {
                            context.Fail("The authorization response has a wrong or missing iss parameter.");
                        }
                    },

3 つ目は、お使いの環境の鍵で署名されていない ID トークンを拒否します。ASP.NET Core は、トークンエンドポイントから受け取った ID トークンの署名検証を省略します。OpenID Connect Core は TLS 上でこれを許可しています。このアプリは、それでも検証します。

                    OnTokenResponseReceived = async context =>
                    {
                        // ASP.NET Core skips the signature check of an ID token that comes from the token endpoint (OpenID
                        // Connect Core 3.1.3.7 allows it over TLS). This app does not: the token must be signed by a key
                        // of the environment, with an allowed algorithm.
                        var configuration = await context.Options.ConfigurationManager!
                            .GetConfigurationAsync(context.HttpContext.RequestAborted);
                        var parameters = context.Options.TokenValidationParameters.Clone();
                        parameters.ValidIssuer = configuration.Issuer;
                        parameters.IssuerSigningKeys = configuration.SigningKeys;
                        parameters.ValidAudience = settings.ClientId;
                        var result = await new JsonWebTokenHandler().ValidateTokenAsync(
                            context.TokenEndpointResponse.IdToken ?? "", parameters);
                        if (!result.IsValid)
                        {
                            context.Fail("The ID token is not signed by a key of the environment.");
                        }
                    },

最後の 2 つのイベントは、ログアウト後のリダイレクト URI をサインアウトリクエストに設定し、サインインが失敗した場合は理由を表示せずにホームページへ戻します。

                    OnRedirectToIdentityProviderForSignOut = context =>
                    {
                        context.ProtocolMessage.PostLogoutRedirectUri = settings.PostLogoutRedirectUri;
                        return Task.CompletedTask;
                    },
                    OnRemoteFailure = context =>
                    {
                        // Do not show the reason in the browser; the logs have it.
                        context.Response.Redirect("/?error=signin");
                        context.HandleResponse();
                        return Task.CompletedTask;
                    },

5. サインイン、ページの保護、サインアウト

/login はサインインを開始し、その後ダッシュボードに戻ります。戻り先は固定のローカルパスです。

public class LoginModel : PageModel
{
    /// <summary>Starts sign-in: redirects to KeenAuth, and back to the dashboard afterwards (a fixed local path).</summary>
    public IActionResult OnGet() =>
        Challenge(new AuthenticationProperties { RedirectUri = "/dashboard" }, OpenIdConnectDefaults.AuthenticationScheme);
}

ダッシュボードにはセッションが必要です。セッションがなければ、ASP.NET Core がサインインを開始します。サインアウトは POST で行われ、Razor Pages がアンチフォージェリトークンを検証します。ここでセッションを終了してから、KeenAuth にリダイレクトします(id_token_hint と post_logout_redirect_uri を使う OpenID Connect RP-Initiated Logout)。

public class LogoutModel : PageModel
{
    /// <summary>
    /// POST only, and Razor Pages checks the antiforgery token. Ends the session here, then redirects to KeenAuth
    /// (OpenID Connect RP-Initiated Logout with id_token_hint and post_logout_redirect_uri).
    /// </summary>
    public IActionResult OnPost() =>
        SignOut(new AuthenticationProperties(), CookieAuthenticationDefaults.AuthenticationScheme,
            OpenIdConnectDefaults.AuthenticationScheme);
}

ホームページはサインインへのリンクを表示し、サインアウトへ POST します。form タグヘルパーがアンチフォージェリトークンを追加します。

    @if (User.Identity?.IsAuthenticated == true)
    {
      <p>Signed in as @Model.DisplayName.</p>
      <p><a href="/dashboard">Dashboard</a></p>
      <!-- The form tag helper adds the antiforgery token. -->
      <form method="post" asp-page="/Logout"><button type="submit">Sign out</button></form>
    }
    else
    {
      <p><a href="/login">Sign in</a></p>
    }

6. API を呼び出す

ダッシュボードはセッションからアクセストークンを取り出し、それを使ってお使いの API を呼び出します。トークンはサーバー上に留まり、ブラウザが受け取るのは HTML だけです。

/// <summary>Needs a session. The access token stays on the server; the browser receives only the HTML.</summary>
[Authorize]
public class DashboardModel(MessagesClient messages) : PageModel
{
    public string DisplayName => User.Identity?.Name ?? User.FindFirst("sub")?.Value ?? "";

    public int Status { get; private set; }

    public string? Messages { get; private set; }

    public async Task OnGetAsync()
    {
        var accessToken = await HttpContext.GetTokenAsync("access_token");
        (Status, Messages) = await messages.FetchAsync(accessToken ?? "", HttpContext.RequestAborted);
    }
}
    public async Task<(int Status, string? Body)> FetchAsync(string accessToken, CancellationToken cancel)
    {
        using var request = new HttpRequestMessage(HttpMethod.Get, "api/messages");
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        try
        {
            using var response = await http.SendAsync(request, cancel);
            return ((int)response.StatusCode, response.IsSuccessStatusCode ? await response.Content.ReadAsStringAsync(cancel) : null);
        }
        catch (HttpRequestException)
        {
            return (502, null);
        }
    }

この例にはリフレッシュトークンがありません。アクセストークンの期限が切れると、API は 401 を返し、ダッシュボードはそのステータスを表示します。その場合は、もう一度サインインしてください。

7. アプリを実行する

Express のクイックスタートの API(またはそのトークンを受け付ける任意の API)を起動し、変数をエクスポートした状態でアプリを実行します。

ASPNETCORE_URLS=http://localhost:8080 dotnet run --project App

http://localhost:8080 を開き、サインイン を選択し、KeenAuth でサインインして、ダッシュボードを開きます。

8. ネットワークなしでテストする

テストにはネットワークも KeenAuth のアカウントも不要です。ループバックポート上の小さな Kestrel サーバーが、お使いの環境(ディスカバリ、テスト実行用に作られた鍵セット、トークンエンドポイント、プッシュ認可リクエスト、エンドセッションエンドポイント)と API の役割を担います。テストは別のポートで実際のアプリを起動し、リダイレクトに従わない HTTP クライアントで操作します。これは、結果を読み取れるブラウザのようなものです。

    [Fact]
    public async Task APageWithoutASessionStartsSignInAndCallsNoApi()
    {
        using var response = await h.Get("/dashboard");
        Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
        Assert.StartsWith(h.Fake.Issuer + "/oauth2/authorize?", response.Headers.Location!.ToString());
        Assert.Empty(h.Fake.To("/api/messages"));
    }
dotnet restore Tests --locked-mode
python3 ../scripts/nuget_deps.py    # licences and exact pins (--age: 7-day rule, network)
dotnet test Tests --no-restore

テストでは次の点を確認します。設定が起動時に検証されること(およびどのエラーにもシークレットが含まれないこと)。サインインで PKCE S256、state、nonce、resource が送られること、また、コードが対応する verifier で交換されること。プッシュ認可リクエストによって、パラメータがブラウザに現れないこと。アクセストークンが Authorization ヘッダーで API に送られ、Cookie やページには決して入らないこと。不明または再利用された state、誤った、または欠けた iss、そして署名されていない ID トークン、別の鍵で署名された ID トークン、公開鍵を鍵とする HMAC で署名された ID トークン、別のオーディエンス・nonce・発行者向けに発行された ID トークン、すでに期限切れの ID トークンが、セッションなしで拒否されること。セッションのないページがサインインを開始すること。そして、サインアウトにはアンチフォージェリトークンが必要で、id_token_hint を付けてエンドセッションエンドポイントへ向かい、サーバー上でセッションを終了すること。

実際のブラウザによる完全なサインインには KeenAuth の環境が必要です。これはオフラインテストには含まれません。

プロキシまたはロードバランサーの背後で使用する

APP_BASE_URL にアプリの公開 URL を設定します。リダイレクト URI は Host ヘッダーからではなく、これから組み立てられます。プロキシが TLS を終端する場合は、プロキシだけがアプリへの経路である場合に限り、転送ヘッダーのミドルウェアを追加します。これにより、セッション Cookie に Secure フラグが付きます。

現在の制限

次のステップ