
# 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 の両方でセッションを終了します。アンチフォージェリトークンが必要です |

## 始める前に

- KeenAuth の環境。その発行者は `https://your-environment.eu.keenauth.app` のような形式です。
- 環境内の Web アプリケーション（[アプリケーションを作成する](/docs/api/management/applications/create)）。クライアントシークレットを持つこと。ハンドラーはトークンリクエストの本文でシークレットを送るため、`client_secret_post` を選択します。`development` 環境では、リダイレクト URI `http://localhost/signin-oidc` と、ログアウト後のリダイレクト URI `http://localhost/` を登録します。ループバック URI はポートなしで登録され、`8080` などの任意のポートに一致します。
- 環境に登録された API（[API を作成する](/docs/api/management/apis/create)）。`messages:read` スコープを持つこと。たとえば `https://api.example.com` のような `identifier` が、そのアクセストークンのオーディエンスになります。[Express のクイックスタート](/docs/quickstarts/express)では、これらのトークンを受け付ける API を構築します。
- .NET 10 SDK（10.0.403 以降のパッチ版）。

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

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

```xml
    <!-- 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]" />
```

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

```sh
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` は、起動時にこれらの値を読み込み、検証します。欠けている値や使えない値があるとアプリは停止し、どのエラーメッセージにも値は含まれません。

```csharp
    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`）に保持されます。

```csharp
            .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）。

```csharp
                // 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 はこれを公開しています。

```csharp
                    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 上でこれを許可しています。このアプリは、それでも検証します。

```csharp
                    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 をサインアウトリクエストに設定し、サインインが失敗した場合は理由を表示せずにホームページへ戻します。

```csharp
                    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` はサインインを開始し、その後ダッシュボードに戻ります。戻り先は固定のローカルパスです。

```csharp
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）。

```csharp
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 タグヘルパーがアンチフォージェリトークンを追加します。

```cshtml
    @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 だけです。

```csharp
/// <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);
    }
}
```

```csharp
    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）を起動し、変数をエクスポートした状態でアプリを実行します。

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

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

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

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

```csharp
    [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"));
    }
```

```sh
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` フラグが付きます。

## 現在の制限

- サインインは `/login` への `GET` で開始されます。コールバックを守るのは `state` の検証と PKCE です。
- ハンドラーは、`client_secret_basic` ではなく `client_secret_post` でクライアントを認証します。
- このアプリは RS256、ES256、PS256 で署名された ID トークンを受け付けます。KeenAuth は EdDSA でも署名できますが、この例では受け付けず、EdDSA は試していません。Web アプリケーションの環境では、これら 3 つのアルゴリズムのいずれかを維持してください。
- チケットストア、そしてアンチフォージェリトークンと state Cookie を保護する鍵は、1 つのプロセスのメモリ内にあります。再起動後はセッションが終わり、進行中のサインインは失敗します。複数のサーバーで動かす場合は、チケットを共有キャッシュに保持し、ASP.NET Core Data Protection の鍵を永続化してください。
- リフレッシュトークンはなく、サイレントサインインもありません。

## 次のステップ

- [Express API を保護する](/docs/quickstarts/express): このアプリが呼び出す API です。
- [トークンの検証](/docs/sdk/token-verification): お使いの API のミドルウェアの下で動く検証処理です。
