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
- A KeenAuth environment. Its issuer looks like
https://your-environment.eu.keenauth.app. - A web application in the environment (Create an application) with a client secret. The handler sends the secret in the token request body, so choose
client_secret_post. In adevelopmentenvironment, register the redirect URIhttp://localhost/signin-oidcand the post-logout redirect URIhttp://localhost/; a loopback URI is registered without a port and matches any port, such as8080. - An API registered in the environment (Create an API) with the
messages:readscope. Itsidentifier, for examplehttps://api.example.com, is the audience of its access tokens. The Express quickstart builds an API that accepts these tokens. - The .NET 10 SDK (10.0.403 or a later patch).
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
- Sign-in starts with a
GETto/login. Thestatecheck and PKCE protect the callback. - The handler authenticates the client with
client_secret_post, notclient_secret_basic. - The app accepts ID tokens signed with RS256, ES256 or PS256. KeenAuth can also sign with EdDSA; this example does not accept it, and EdDSA was not tried. Keep one of the three algorithms for the web application's environment.
- The ticket store and the keys that protect the antiforgery token and the state cookies live in the memory of one process. After a restart, sessions end and sign-ins in progress fail. With several servers, keep the tickets in a shared cache and persist the ASP.NET Core Data Protection keys.
- No refresh token and no silent sign-in.
Next steps
- Protect an Express API: the API this app calls.
- Verifying tokens: the verifier under the middleware of your API.