Anmeldung zu einer ASP.NET-Core-App hinzufügen

Diese Schnellstart-Anleitung fügt eine KeenAuth-Anmeldung zu einer ASP.NET Core 10 Razor-Pages-App hinzu, mit Microsoft.AspNetCore.Authentication.OpenIdConnect, dem Standard-OpenID-Connect-Client von ASP.NET Core. KeenAuth hat noch kein .NET SDK, und es wird keines benötigt: KeenAuth ist ein Standard-OpenID-Connect-Provider. Ihre App ist der OAuth-Client und behält alle Tokens auf dem Server; der Browser erhält nur ein Cookie, das einen zufälligen Sitzungsschlüssel enthält. Sie konfigurieren die Anmeldung, schützen eine Seite, rufen eine geschützte API mit dem Access Token des Benutzers auf, melden sich ab und testen die App offline.

Die App hat diese Routen:

Route Funktion
GET / Öffentliche Startseite: ein Anmeldelink oder der Name des Benutzers und eine Schaltfläche zum Abmelden
GET /login Startet die Anmeldung: leitet zu KeenAuth weiter
GET /signin-oidc Wird vom OpenID-Connect-Handler bereitgestellt: der Callback
GET /dashboard Erfordert eine Sitzung. Zeigt den Benutzer und die Nachrichten, die Ihre API zurückgibt
POST /logout Beendet die Sitzung hier und bei KeenAuth. Benötigt das Antiforgery-Token

Bevor Sie beginnen

1. Paket hinzufügen

Die App hat ein Paket, das auf eine exakte Version fixiert ist. Die Lock-Datei (packages.lock.json, aktiviert mit RestorePackagesWithLockFile) fixiert jedes andere Paket mit seinem Inhalts-Hash.

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

Stellen Sie im Locked Mode wieder her, damit eine Änderung am Abhängigkeitsgraphen fehlschlägt, statt stillschweigend durchzulaufen:

dotnet restore --locked-mode

2. Konfigurieren

Die App liest ihre Konfiguration nur aus Umgebungsvariablen. Committen Sie deren Werte niemals.

Variable Pflicht Bedeutung
KEENAUTH_ISSUER ja Der Issuer Ihrer Umgebung, https
KEENAUTH_CLIENT_ID ja Die client_id der Webanwendung
KEENAUTH_CLIENT_SECRET ja Ihr Client Secret
APP_BASE_URL ja Die öffentliche URL der App. Die Redirect-URI wird daraus gebildet. http nur für localhost, 127.0.0.1 oder [::1]
API_URL ja Die API, die die App aufruft, zum Beispiel http://localhost:3001
KEENAUTH_API_AUDIENCE ja Der Identifier dieser API in KeenAuth
SESSION_SECURE_COOKIE nein false nur für einfaches http://localhost

KeenAuthSettings.From liest diese Werte beim Start ein und prüft sie. Ein fehlender oder unbrauchbarer Wert stoppt die App, und keine Fehlermeldung enthält einen Wert.

    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. Sitzung auf dem Server halten

Das Cookie-Schema meldet den Benutzer bei Ihrer App an. Das Cookie enthält nur einen zufälligen Schlüssel; Benutzer und Tokens bleiben in einem Ticket-Speicher auf dem Server (MemoryTicketStore, im Speicher eines Prozesses).

            .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-Handler konfigurieren

Der Handler liest Discovery (<issuer>/.well-known/openid-configuration) und die Signaturschlüssel Ihrer Umgebung. Er verwendet den Authorization-Code-Flow mit PKCE (S256), state und nonce und prüft sie beim Rücksprung. Nennt Discovery einen Endpunkt für Pushed Authorization Requests, verwendet der Handler ihn (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;

Drei Events vervollständigen den Handler. Das erste fügt die Identifier Ihrer API als resource hinzu (RFC 8707); ohne sie ist das Access Token nicht für Ihre API bestimmt, und die API lehnt es ab. Das zweite prüft den Parameter iss der Autorisierungsantwort (RFC 9207): ASP.NET Core prüft ihn nicht, KeenAuth bietet ihn aber an.

                    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.");
                        }
                    },

Das dritte lehnt ein ID Token ab, das nicht von einem Schlüssel Ihrer Umgebung signiert ist. ASP.NET Core überspringt die Signaturprüfung eines ID Tokens, das vom Token-Endpunkt kommt, was OpenID Connect Core über TLS erlaubt; diese App prüft es trotzdem.

                    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.");
                        }
                    },

Die letzten beiden Events setzen die Post-Logout-Redirect-URI in die Abmeldeanfrage und schicken eine fehlgeschlagene Anmeldung zur Startseite zurück, ohne den Grund anzuzeigen:

                    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. Anmelden, Seite schützen und abmelden

/login startet die Anmeldung und kehrt danach zum Dashboard zurück, einem festen lokalen Pfad:

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);
}

Das Dashboard erfordert eine Sitzung: Ohne eine startet ASP.NET Core die Anmeldung. Abmelden ist ein POST, und Razor Pages prüft das Antiforgery-Token. Es beendet die Sitzung hier und leitet dann zu KeenAuth weiter (OpenID Connect RP-Initiated Logout mit id_token_hint und post_logout_redirect_uri):

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);
}

Die Startseite verlinkt zur Anmeldung und sendet das Formular zum Abmelden; der Form-Tag-Helper fügt das Antiforgery-Token hinzu:

    @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. Ihre API aufrufen

Das Dashboard nimmt das Access Token aus der Sitzung und ruft damit Ihre API auf. Das Token bleibt auf dem Server; der Browser erhält nur das 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);
        }
    }

Das Beispiel hat kein Refresh Token: Läuft das Access Token ab, antwortet Ihre API mit 401, und das Dashboard zeigt diesen Status an. Melden Sie sich erneut an.

7. App starten

Starten Sie die API aus dem Express-Schnellstart (oder eine beliebige API, die die Tokens akzeptiert) und starten Sie die App mit exportierten Variablen:

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

Öffnen Sie http://localhost:8080, wählen Sie Sign in, melden Sie sich bei KeenAuth an und öffnen Sie das Dashboard.

8. Ohne Netzwerk testen

Die Tests benötigen kein Netzwerk und kein KeenAuth-Konto. Ein kleiner Kestrel-Server auf einem Loopback-Port übernimmt die Rolle Ihrer Umgebung (Discovery, ein für den Testlauf erstellter Schlüsselsatz, der Token-Endpunkt, Pushed Authorization Requests, der End-Session-Endpunkt) und Ihrer API. Die Tests starten die echte App auf einem anderen Port und steuern sie mit einem HTTP-Client, der Weiterleitungen nicht folgt, wie ein Browser, den Sie auslesen können:

    [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

Die Tests prüfen, dass die Konfiguration beim Start validiert wird (und dass kein Fehler ein Secret enthält); dass die Anmeldung PKCE S256, state, nonce und resource sendet und dass der Code mit dem passenden Verifier eingetauscht wird; dass eine Pushed Authorization Request die Parameter aus dem Browser heraushält; dass das Access Token im Header Authorization an die API geht und nie in ein Cookie oder eine Seite gelangt; dass ein unbekannter oder wiederverwendeter State, ein falscher oder fehlender iss, sowie ein ID Token, das unsigniert ist, mit einem anderen Schlüssel signiert wurde, mit einer HMAC signiert wurde, deren Schlüssel der öffentliche Schlüssel ist, oder für eine andere Audience, einen anderen Nonce oder Issuer ausgestellt oder bereits abgelaufen ist, ohne Sitzung abgelehnt werden; dass eine Seite ohne Sitzung die Anmeldung startet; und dass das Abmelden das Antiforgery-Token benötigt, zum End-Session-Endpunkt mit id_token_hint geht und die Sitzung auf dem Server beendet.

Die vollständige Anmeldung mit einem echten Browser benötigt eine KeenAuth-Umgebung. Sie ist nicht Teil der Offline-Tests.

Hinter einem Proxy oder Load Balancer

Setzen Sie APP_BASE_URL auf die öffentliche URL der App; die Redirect-URI wird daraus gebildet, nicht aus dem Header Host. Beendet ein Proxy TLS, fügen Sie die Middleware für weitergeleitete Header nur hinzu, wenn der Proxy der einzige Weg zur App ist, damit die Session-Cookies das Flag Secure erhalten.

Aktuelle Grenzen

Nächste Schritte