Add sign-in to an ASP.NET Core app

This quickstart adds KeenAuth sign-in to an ASP.NET Core 10 Razor Pages app with Microsoft.AspNetCore.Authentication.OpenIdConnect, the standard OpenID Connect client of ASP.NET Core. KeenAuth has no .NET SDK yet, and none is needed: KeenAuth is a standard OpenID Connect provider. Your app is the OAuth client and keeps every token on the server; the browser gets only a cookie that holds a random session key. You configure sign-in, protect a page, call a protected API with the user's access token, sign out and test the app offline.

The app has these routes:

Route What it does
GET / Public home page: a sign-in link, or the user's name and a sign-out button
GET /login Starts sign-in: redirects to KeenAuth
GET /signin-oidc Served by the OpenID Connect handler: the callback
GET /dashboard Needs a session. Shows the user and the messages that your API returns
POST /logout Ends the session here and at KeenAuth. Needs the antiforgery token

Before you begin

1. Add the package

The app has one package, pinned to an exact version. The lock file (packages.lock.json, switched on with RestorePackagesWithLockFile) pins every other package, with its content 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]" />

Restore in locked mode, so a change in the dependency graph fails instead of passing silently:

dotnet restore --locked-mode

2. Configure

The app reads its configuration from environment variables only. Never commit their values.

Variable Required Meaning
KEENAUTH_ISSUER yes Your environment's issuer, https
KEENAUTH_CLIENT_ID yes The client_id of the web application
KEENAUTH_CLIENT_SECRET yes Its client secret
APP_BASE_URL yes The public URL of the app. The redirect URI is built from it. http only for localhost, 127.0.0.1 or [::1]
API_URL yes The API the app calls, for example http://localhost:3001
KEENAUTH_API_AUDIENCE yes The identifier of that API in KeenAuth
SESSION_SECURE_COOKIE no false only for plain http://localhost

KeenAuthSettings.From reads and checks these values at start-up. A missing or unusable value stops the app, and no error message contains a value.

    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. Keep the session on the server

The cookie scheme signs the user in to your app. Its cookie holds only a random key; the user and the tokens stay in a ticket store on the server (MemoryTicketStore, in the memory of one process).

            .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. Configure the OpenID Connect handler

The handler reads discovery (<issuer>/.well-known/openid-configuration) and the signing keys of your environment. It uses the authorization code flow with PKCE (S256), state and nonce, and it checks them on the way back. If discovery names a pushed authorization request endpoint, the handler uses it (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;

Three events complete the handler. The first adds your API's identifier as resource (RFC 8707); without it, the access token is not meant for your API and the API rejects it. The second checks the iss parameter of the authorization response (RFC 9207): ASP.NET Core does not check it, and KeenAuth advertises it.

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

The third refuses an ID token that is not signed by a key of your environment. ASP.NET Core skips the signature check of an ID token that comes from the token endpoint, which OpenID Connect Core allows over TLS; this app checks it anyway.

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

The last two events put the post-logout redirect URI into the sign-out request and send a failed sign-in back to the home page without showing the reason:

                    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. Sign in, protect a page and sign out

/login starts sign-in and returns to the dashboard afterwards, a fixed local path:

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

The dashboard needs a session: without one, ASP.NET Core starts sign-in. Sign-out is a POST, and Razor Pages checks the antiforgery token. It ends the session here, then redirects to KeenAuth (OpenID Connect RP-Initiated Logout with id_token_hint and 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);
}

The home page links to sign-in and posts to sign-out; the form tag helper adds the antiforgery token:

    @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. Call your API

The dashboard takes the access token from the session and calls your API with it. The token stays on the server; the browser receives only the 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);
        }
    }

The example has no refresh token: when the access token expires, your API answers 401 and the dashboard shows that status. Sign in again.

7. Run the app

Start the API from the Express quickstart (or any API that accepts the tokens) and run the app with the variables exported:

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

Open http://localhost:8080, choose Sign in, sign in at KeenAuth and open the dashboard.

8. Test without a network

The tests need no network and no KeenAuth account. A small Kestrel server on a loopback port plays your environment (discovery, a key set made for the test run, the token endpoint, pushed authorization requests, the end-session endpoint) and your API. The tests start the real app on another port and drive it with an HTTP client that does not follow redirects, like a browser that you can read:

    [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

The tests check that the configuration is validated at start-up (and that no error contains a secret); that sign-in sends PKCE S256, state, nonce and resource, and that the code is exchanged with the matching verifier; that a pushed authorization request keeps the parameters out of the browser; that the access token goes to the API in the Authorization header and never into a cookie or a page; that an unknown or reused state, a wrong or missing iss, and an ID token that is unsigned, signed with another key, signed with an HMAC keyed by the public key, or issued for another audience, nonce or issuer or already expired are refused without a session; that a page without a session starts sign-in; and that sign-out needs the antiforgery token, goes to the end-session endpoint with id_token_hint and ends the session on the server.

The full sign-in with a real browser needs a KeenAuth environment. It is not part of the offline tests.

Behind a proxy or load balancer

Set APP_BASE_URL to the public URL of the app; the redirect URI is built from it, not from the Host header. If a proxy ends TLS, add the forwarded headers middleware only when the proxy is the only way to reach the app, so the session cookies get the Secure flag.

Current limits

Next steps