Aggiungi l'accesso a un'app ASP.NET Core

Questa guida rapida aggiunge l'accesso con KeenAuth a un'app ASP.NET Core 10 Razor Pages usando Microsoft.AspNetCore.Authentication.OpenIdConnect, il client OpenID Connect standard di ASP.NET Core. KeenAuth non ha ancora un SDK per .NET, e non serve: KeenAuth è un provider OpenID Connect standard. La tua app è il client OAuth e mantiene tutti i token sul server; il browser riceve solo un cookie che contiene una chiave di sessione casuale. Configuri l'accesso, proteggi una pagina, chiami un'API protetta con l'access token dell'utente, ti disconnetti e testi l'app offline.

L'app ha queste route:

Route Cosa fa
GET / Home page pubblica: un link per accedere, oppure il nome dell'utente e un pulsante per disconnettersi
GET /login Avvia l'accesso: reindirizza a KeenAuth
GET /signin-oidc Servita dall'handler OpenID Connect: il callback
GET /dashboard Richiede una sessione. Mostra l'utente e i messaggi restituiti dalla tua API
POST /logout Termina la sessione qui e su KeenAuth. Richiede il token antiforgery

Prima di iniziare

1. Aggiungi il pacchetto

L'app ha un solo pacchetto, fissato a una versione esatta. Il lock file (packages.lock.json, attivato con RestorePackagesWithLockFile) fissa tutti gli altri pacchetti, con il loro hash del contenuto.

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

Esegui il restore in modalità locked, così una modifica nel grafo delle dipendenze fallisce invece di passare in silenzio:

dotnet restore --locked-mode

2. Configura

L'app legge la configurazione solo dalle variabili d'ambiente. Non committare mai i loro valori.

Variabile Obbligatoria Significato
KEENAUTH_ISSUER sì L'issuer del tuo ambiente, https
KEENAUTH_CLIENT_ID sì Il client_id dell'applicazione web
KEENAUTH_CLIENT_SECRET sì Il suo client secret
APP_BASE_URL sì L'URL pubblico dell'app. Il redirect URI viene costruito a partire da esso. http solo per localhost, 127.0.0.1 o [::1]
API_URL sì L'API che l'app chiama, per esempio http://localhost:3001
KEENAUTH_API_AUDIENCE sì L'identificatore di quella API in KeenAuth
SESSION_SECURE_COOKIE no false solo per http://localhost senza TLS

KeenAuthSettings.From legge e verifica questi valori all'avvio. Un valore mancante o non utilizzabile ferma l'app, e nessun messaggio di errore contiene un valore.

    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. Mantieni la sessione sul server

loschema dei cookie accede l'utente alla tua app. Il suo cookie contiene solo una chiave casuale; l'utente e i token restano in un ticket store sul server (MemoryTicketStore, in memoria di un singolo processo).

            .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. Configura l'handler OpenID Connect

L'handler legge la discovery (<issuer>/.well-known/openid-configuration) e le chiavi di firma del tuo ambiente. Usa il flusso authorization code con PKCE (S256), state e nonce, e li verifica al ritorno. Se la discovery indica un endpoint di pushed authorization request, l'handler lo usa (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;

Tre eventi completano l'handler. Il primo aggiunge l'identificatore della tua API come resource (RFC 8707); senza di esso, l'access token non è destinato alla tua API e l'API lo rifiuta. Il secondo verifica il parametro iss della risposta di autorizzazione (RFC 9207): ASP.NET Core non lo verifica, mentre KeenAuth lo pubblicizza.

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

Il terzo rifiuta un ID token che non è firmato con una chiave del tuo ambiente. ASP.NET Core salta la verifica della firma di un ID token che proviene dal token endpoint, cosa che OpenID Connect Core consente su TLS; questa app la esegue comunque.

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

Gli ultimi due eventi inseriscono il post-logout redirect URI nella richiesta di disconnessione e rimandano alla home page un accesso fallito senza mostrare il motivo:

                    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. Accedi, proteggi una pagina e disconnettiti

/login avvia l'accesso e poi torna alla dashboard, un percorso locale fisso:

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

La dashboard richiede una sessione: senza di essa, ASP.NET Core avvia l'accesso. La disconnessione è una POST, e Razor Pages verifica il token antiforgery. Termina la sessione qui, poi reindirizza a KeenAuth (OpenID Connect RP-Initiated Logout con id_token_hint e 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);
}

La home page contiene il link per accedere e invia un form per disconnettersi; il tag helper del form aggiunge il token antiforgery:

    @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. Chiama la tua API

La dashboard prende l'access token dalla sessione e chiama la tua API con esso. Il token resta sul server; il browser riceve solo l'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);
        }
    }

L'esempio non ha un refresh token: quando l'access token scade, la tua API risponde 401 e la dashboard mostra quello stato. Accedi di nuovo.

7. Esegui l'app

Avvia l'API della guida rapida Express (o qualsiasi API che accetti i token) ed esegui l'app con le variabili esportate:

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

Apri http://localhost:8080, scegli Accedi, accedi su KeenAuth e apri la dashboard.

8. Testa senza rete

I test non richiedono rete né un account KeenAuth. Un piccolo server Kestrel su una porta di loopback simula il tuo ambiente (discovery, un set di chiavi creato per l'esecuzione dei test, il token endpoint, le pushed authorization request, l'end-session endpoint) e la tua API. I test avviano l'app reale su un'altra porta e la pilotano con un client HTTP che non segue i redirect, come un browser che puoi leggere:

    [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

I test verificano che la configurazione sia validata all'avvio (e che nessun errore contenga un secret); che l'accesso invii PKCE S256, state, nonce e resource, e che il codice venga scambiato con il verifier corrispondente; che una pushed authorization request tenga i parametri fuori dal browser; che l'access token arrivi alla API nell'header Authorization e non finisca mai in un cookie o in una pagina; che uno state sconosciuto o riutilizzato, un iss errato o mancante, e un ID token non firmato, firmato con un'altra chiave, firmato con un HMAC che usa la chiave pubblica, oppure emesso per un'altra audience, un altro nonce o un altro issuer, oppure già scaduto, vengano rifiutati senza creare una sessione; che una pagina senza sessione avvii l'accesso; e che la disconnessione richieda il token antiforgery, vada all'end-session endpoint con id_token_hint e termini la sessione sul server.

L'accesso completo con un browser reale richiede un ambiente KeenAuth. Non fa parte dei test offline.

Dietro un proxy o un load balancer

Imposta APP_BASE_URL sull'URL pubblico dell'app; il redirect URI viene costruito a partire da esso, non dall'header Host. Se un proxy termina TLS, aggiungi il middleware degli header forwarded solo quando il proxy è l'unico modo per raggiungere l'app, così i cookie di sessione ricevono il flag Secure.

Limiti attuali

Prossimi passi