Get started
API scopes
Integration guides
Features
Troubleshooting
C# / .NET MVC
ASP.NET Core's built-in OpenIdConnect handler integrates Kenni without any additional packages beyond what's in the framework. Authority discovery, PKCE, and token refresh are all handled for you.
Packages
Both packages ship with .NET 8+:
Microsoft.AspNetCore.Authentication.Cookies
Microsoft.AspNetCore.Authentication.OpenIdConnect
If you also want to verify access tokens on a protected API, add:
Microsoft.AspNetCore.Authentication.JwtBearer
Program.cs
ASP.NET's default JwtSecurityTokenHandler rewrites OIDC claim names (sub → http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier, name → …/claims/name, etc.) before the user's claims principal is built. So User.FindFirst("sub") returns null in handlers even though sub is right there in the token. Disable the remap globally and on every JWT-consuming handler.
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
// Disable the legacy SOAP claim-name remap globally.
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = config["KENNI_ISSUER"]; // https://idp.kenni.is/<team-domain>
options.ClientId = config["KENNI_CLIENT_ID"]; // @<team-domain>/web
options.ClientSecret = config["KENNI_CLIENT_SECRET"];
options.ResponseType = "code";
options.UsePkce = true;
options.SaveTokens = true;
// Kenni's id_token already carries the claims we need; skip the extra round-trip.
options.GetClaimsFromUserInfoEndpoint = false;
options.MapInboundClaims = false;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("national_id");
options.Scope.Add("offline_access");
// Optional: add a custom API scope if your application has one.
// options.Scope.Add(config["KENNI_API_SCOPE"]);
});
builder.Services.AddAuthorization();
builder.Services.AddControllersWithViews();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();
Authority is your team's issuer URL. The handler fetches the discovery document automatically and uses it to configure every other endpoint.
UsePkce = true is set explicitly — defence in depth even for confidential clients.
Read configuration from environment variables, user secrets, or appsettings.json. Don't paste your client_secret into source — that's the kind of mistake that ends up in version control.
Redirect URI registration
The OIDC handler's default callback path is /signin-oidc. Combined with a local port (the rest of this guide assumes 5000), the redirect URI to register in the developer portal is:
http://localhost:5000/signin-oidc
The default post-logout callback path is /signout-callback-oidc. Pin your dev server to a single HTTP URL (edit Properties/launchSettings.json) so you only register one redirect URI. The defaults work over plain HTTP for local dev — don't add CookieSecurePolicy.Always or SameSiteMode.None unless you've already wired up dotnet dev-certs.
Reading claims
With MapInboundClaims = false, claims keep their OIDC names:
var sub = User.FindFirst("sub")?.Value;
var nationalId = User.FindFirst("national_id")?.Value;
var name = User.Identity?.Name;
Reading the access token
SaveTokens = true stores the tokens against the cookie. Retrieve them via HttpContext.GetTokenAsync:
using Microsoft.AspNetCore.Authentication;
var accessToken = await HttpContext.GetTokenAsync("access_token");
var idToken = await HttpContext.GetTokenAsync("id_token");
var refreshToken = await HttpContext.GetTokenAsync("refresh_token");
Verifying access tokens on a protected API
If your application also exposes an API protected by a custom scope, validate the bearer JWT and enforce the scope. Issuer / audience / expiry / signature are not enough — the resource server also needs to check that the token was issued for its scope.
builder.Services
.AddAuthentication() // already added above
.AddJwtBearer(options =>
{
options.Authority = config["KENNI_ISSUER"];
options.Audience = config["KENNI_CLIENT_ID"]; // or your configured API audience
options.MapInboundClaims = false;
options.TokenValidationParameters.ValidateIssuer = true;
options.TokenValidationParameters.ValidateAudience = true;
options.TokenValidationParameters.ValidateLifetime = true;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
policy.AuthenticationSchemes = new[] { JwtBearerDefaults.AuthenticationScheme };
policy.RequireAssertion(ctx =>
{
var scope = ctx.User.FindFirst("scope")?.Value ?? "";
return scope.Split(' ').Contains(config["KENNI_API_SCOPE"]);
});
});
});
Then on the protected controller:
[Authorize(Policy = "ApiScope", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public IActionResult ProtectedResource() => Ok(new { ok = true });
Public clients
For SPA or Native clients, drop ClientSecret and let PKCE carry the token request:
options.ClientSecret = null;
options.UsePkce = true;
Sign out
Calling SignOutAsync on the cookie scheme alone clears the local session but leaves the Kenni session alive — the next sign-in silently re-authenticates. Sign out of both schemes so the OpenID Connect handler also redirects through Kenni's end_session_endpoint:
[Authorize]
public IActionResult Logout()
{
return SignOut(
new AuthenticationProperties { RedirectUri = "/" },
CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme);
}
The handler reads end_session_endpoint from discovery and sends id_token_hint and post_logout_redirect_uri automatically (because SaveTokens = true made the id_token available). Register the resolved post-logout URL (e.g. http://localhost:5000/signout-callback-oidc) on the application's Post logout redirect URIs list in the developer portal. See the No framework (curl) guide for the full reasoning.