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 (subhttp://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.

Next steps