SoftwareDriven.Authentication.ApiKey.Server
5.0.0
dotnet add package SoftwareDriven.Authentication.ApiKey.Server --version 5.0.0
NuGet\Install-Package SoftwareDriven.Authentication.ApiKey.Server -Version 5.0.0
<PackageReference Include="SoftwareDriven.Authentication.ApiKey.Server" Version="5.0.0" />
<PackageVersion Include="SoftwareDriven.Authentication.ApiKey.Server" Version="5.0.0" />
<PackageReference Include="SoftwareDriven.Authentication.ApiKey.Server" />
paket add SoftwareDriven.Authentication.ApiKey.Server --version 5.0.0
#r "nuget: SoftwareDriven.Authentication.ApiKey.Server, 5.0.0"
#:package SoftwareDriven.Authentication.ApiKey.Server@5.0.0
#addin nuget:?package=SoftwareDriven.Authentication.ApiKey.Server&version=5.0.0
#tool nuget:?package=SoftwareDriven.Authentication.ApiKey.Server&version=5.0.0
SoftwareDriven.Authentication
Authentication building blocks for ASP.NET Core / Blazor apps talking to an OpenID Connect provider (Keycloak) — plus API key authentication for service-to-service calls.
| Package | Purpose |
|---|---|
SoftwareDriven.Authentication.OpenIdConnect.Shared |
Common models (OidcConfig, AccessTokenResponse) and configuration helpers. |
SoftwareDriven.Authentication.OpenIdConnect.Server |
JWT bearer authentication for APIs (validate access tokens issued by the OIDC provider), Keycloak role flattening, guest scheme. |
SoftwareDriven.Authentication.ApiKey.Server |
X-Api-Key authentication scheme with per-key roles and an authorization requirement. |
SoftwareDriven.Authentication.OpenIdConnect.WebApp.Server |
Blazor Web App server side: cookie session + named OIDC schemes, login/logout/after-login endpoints, transparent token refresh, access-token handoff endpoint, bearer handler for prerendering. |
SoftwareDriven.Authentication.OpenIdConnect.WebApp.Client |
Blazor WebAssembly side of the above: IAccessTokenService, BearerTokenHandler (401 retry), RedirectToLogin component. |
All packages share one version. Target framework: net10.0.
5.0: the former
SoftwareDriven.Authentication.OpenIdConnect.Clientpackage (WASM-side ROPC /RemoteAuthenticatorViewstack with tokens in local storage) was removed and replaced by theWebApp.*pair. See Migration.
OpenIdConnect.Server — JWT bearer for APIs
builder.Services.AddOpenIdConnect(builder.Configuration, jwt =>
{
// optional: adjust JwtBearerOptions after the defaults were applied
jwt.RequireHttpsMetadata = builder.Environment.IsProduction();
});
builder.Services.Configure<KeycloakClaimsTransformationOptions>(o => o.RoleClaimType = ClaimTypes.Role); // default
Configuration (Oidc:*): IssuerUrl (authority), Audience, optional MetadataUrl, ClientId, NameClaim (default preferred_username).
- Registers JWT bearer as default scheme and
ClaimsTransformerKeycloak, which flattensrealm_access.roles/rolesinto role claims on every request (deduplicated). AddGuestAuthentication()registers an always-succeedingGuestscheme (local development).- Diagnostics:
IdentityModelEventSource.ShowPIIis no longer switched on by the package; set it yourself in Development if needed.
ApiKey.Server
// as default scheme
builder.Services.AddApiKeyAuthentication(o => o.AddApiKey("secret-key", ["Reader"]));
// or next to another default scheme (does not touch AddAuthentication defaults)
builder.Services.AddAuthentication(...).AddApiKeySupport(o => o.AddApiKey("secret-key", ["Reader"]));
The key is read from the X-Api-Key header (or ?apiKey= query parameter). Roles configured via AddApiKey become role claims.
ApiKeyRequirement + ApiKeyAuthorizationHandler allow policies restricted to specific keys.
WebApp.Server + WebApp.Client — Blazor Web App (InteractiveWebAssembly)
Architecture
- Login without WebAssembly. The OIDC code flow (authorization code + PKCE) runs entirely on the server; the user gets a
cookie. Login pages can be static SSR — the login buttons are plain links to
/auth/login?scheme=…. - Named schemes instead of subdomains. Every user group / identity provider (e.g.
Teacher,Student,Vidis) is one OIDC scheme in the same app, configured underOidc:{Scheme}. Which scheme a user signed in with is stored as a claim in the cookie ticket (WebAppOidcOptions.SchemeClaimType, defaultsd_auth_scheme) — needed for logout and refresh. - Token handoff instead of BFF proxy. The WASM client fetches the access token from the same-origin endpoint
GET /auth/token(~ once per token lifetime), keeps it in memory and calls the resource APIs directly with a bearer header. The refresh token never leaves the server; nothing is stored in local storage. - Transparent refresh in the cookie validation. When the access token in the ticket is about to expire, the cookie
handler's
OnValidatePrincipalrefreshes it at the identity provider and re-issues the ticket. This keeps prerender / SSR API calls (which take the token from the ticket viaServerBearerTokenHandler) working after inactivity, and it ends the cookie session when the identity provider refuses the refresh (no "zombie login"). The token endpoint therefore is a fast path that almost always just returns the token from the ticket. - Auth state to WASM. Roles are flattened once at sign-in (
KeycloakClaimsEnricher) and reach the client viaAddAuthenticationStateSerialization(o => o.SerializeAllClaims = true)/AddAuthenticationStateDeserialization().
appsettings.json (server)
{
"Oidc": {
"Teacher": { "Authority": "https://idm.example.org/realms/school", "ClientId": "school.teacher" },
"Student": { "Authority": "https://idm.example.org/realms/school", "ClientId": "school.student" },
"Vidis": { "Authority": "https://idm.example.org/realms/school", "ClientId": "school.vidis" }
}
}
Per scheme: Authority, ClientId, ClientSecret (confidential clients only — user secrets / Helm values, never in git),
Scope (default openid profile roles offline_access), DisplayName, NameClaim (default preferred_username),
RequireHttpsMetadata (default true), CallbackPath (default /signin-oidc-{scheme}), SignedOutCallbackPath
(default /signout-callback-oidc-{scheme}). Sections without Authority are skipped, so an environment can offer a subset of
schemes. Register https://{host}/signin-oidc-{scheme} and https://{host}/signout-callback-oidc-{scheme} (lower-case scheme)
as redirect URIs at the identity provider.
Server Program.cs
using SoftwareDriven.Authentication.OpenIdConnect.WebApp.Server;
using SoftwareDriven.Authentication.OpenIdConnect.WebApp.Server.Helper;
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents()
.AddAuthenticationStateSerialization(o => o.SerializeAllClaims = true);
builder.Services.AddOidcWebAppAuthentication(builder.Configuration, sectionName: "Oidc",
configure: o =>
{
o.AfterLoginRedirect = WebAppOidcOptions.RedirectByRole(new Dictionary<string, string>
{
["Teacher"] = "/teacher",
["Student"] = "/student",
});
},
configureCookie: c =>
{
c.LoginPath = "/";
c.ExpireTimeSpan = TimeSpan.FromHours(8);
c.SlidingExpiration = true;
});
// API clients used during prerendering: bearer token from the cookie ticket
builder.Services.AddHttpClient("api", c => c.BaseAddress = new Uri(apiUrl))
.AddHttpMessageHandler<ServerBearerTokenHandler>();
// Replica > 1: shared data protection key ring, otherwise cookies from other instances are unreadable
builder.Services.AddDataProtection().SetApplicationName("my-portal").PersistKeysToFileSystem(new DirectoryInfo("/keys"));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapOidcWebAppEndpoints(); // before MapRazorComponents
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Client._Imports).Assembly);
Building blocks for consumers that own AddAuthentication(): AddOidcWebAppCookie(...), AddOidcSchemes(config, "Oidc"),
AddOidcScheme(new OidcSchemeConfig { ... }), AddOidcWebAppCoreServices().
Client Program.cs
using SoftwareDriven.Authentication.OpenIdConnect.WebApp.Client;
builder.Services.AddOidcWebAppClient(o => o.LoginPath = "/"); // includes AddAuthenticationStateDeserialization()
builder.Services.AddHttpClient("api", c => c.BaseAddress = new Uri(apiUrl))
.AddBearerTokenHandler();
Routes.razor:
@using SoftwareDriven.Authentication.OpenIdConnect.WebApp.Client.Components
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized><RedirectToLogin /></NotAuthorized>
</AuthorizeRouteView>
RedirectToLogin sends unauthenticated users to LoginPath?returnUrl=… (full page load); users that are signed in but lack
the required role see a "no access" message instead of a redirect loop (NoAccessContent, NoAccessTitle, … parameters).
Note that pages with [Authorize(Roles = …)] reached by a full page load are checked by the endpoint authorization on
the server first — a signed-in user without the role is forbidden by the cookie handler (AccessDeniedPath, default
/Account/AccessDenied); set c.AccessDeniedPath = "/no-access" in configureCookie and provide that page.
Login / logout links must bypass enhanced navigation so they work before and after hydration:
<a href="/auth/login?scheme=teacher" data-enhance-nav="false">Teacher login</a>
<a href="/auth/logout" data-enhance-nav="false">Logout</a>
Identity provider hints: /auth/login?scheme=vidis&kc_idp_hint=… — parameters listed in IdpHintParameters are forwarded
to the authorization request.
Forcing a fresh access token / fresh claims
Identity provider mappers may put account attributes into the access token. When such attributes change at runtime
(and the app must guarantee the APIs see them immediately), the client can force a refresh — the replacement for the
former AccessTokenRevoker:
@inject IWebAppAuthSession Session
// ...
await Session.RefreshAsync(); // 1) GET auth/token?refresh=true → server exchanges the refresh token at the IdP,
// re-syncs roles + AccessTokenClaimTypes into the cookie principal, re-issues the cookie
// 2) GET auth/me → WebAppAuthenticationStateProvider raises AuthenticationStateChanged
- Server: list the claim types to mirror from the access token into the cookie principal:
o.AccessTokenClaimTypes.Add("school_id")(applied at sign-in and after every refresh;ResyncClaimsOnRefreshalso replaces role claims by the token's roles).AllowForcedRefresh = falsedisables?refresh=true. - Client:
WebAppAuthenticationStateProvider(registered byAddOidcWebAppClient) starts from the prerendered snapshot and can be re-read fromauth/meat any time (IWebAppAuthSession.RefreshAuthenticationStateAsync());AuthorizeView/AuthorizeRouteViewre-render through the normalAuthenticationStateChangedevent. - Only the token is needed (no UI update)?
IAccessTokenService.RefreshAsync(). - Because the regular cookie refresh runs the same re-sync, claims in the cookie ticket now follow the access token
at every refresh (roles +
AccessTokenClaimTypes); other claims (ID token / user info) still only change at a real login.
Options reference
WebAppOidcOptions (server):
| Option | Default | Notes |
|---|---|---|
CookieScheme |
Cookies |
Scheme the OIDC schemes sign in to. |
SchemeClaimType |
sd_auth_scheme |
Claim remembering the OIDC scheme used. |
IdpHintParameters |
kc_idp_hint, vidis_idp_hint |
Login query parameters passed through to the IdP. |
EndpointPrefix |
/auth |
Route prefix; LoginPath, LogoutPath, TokenPath, AfterLoginPath derive from it. Must match the client's TokenEndpoint ({prefix}/token). |
DefaultRedirectPath |
/ |
Login without / unknown scheme, after-login fallback. |
SchemeNotConfiguredRedirectPath |
/?error=scheme-not-configured |
|
AfterLoginRedirect |
_ => "/" |
Func<ClaimsPrincipal,string>; use WebAppOidcOptions.RedirectByRole(...). |
PostLogoutRedirectPath |
/ |
|
EnrichKeycloakRoles / RoleClaimType |
true / ClaimTypes.Role |
Role flattening at sign-in. |
MinimumRemainingTokenLifetime |
60 s | Refresh threshold (cookie validation and token endpoint). |
AccessTokenClaimTypes |
– | Claims mirrored from the access token into the cookie principal (sign-in + every refresh). |
ResyncClaimsOnRefresh |
true |
Replace roles + AccessTokenClaimTypes from the new token after a refresh. |
AllowForcedRefresh |
true |
Honour {TokenPath}?refresh=true. |
RefreshOnValidatePrincipal |
true |
Transparent refresh + reject-on-failure in the cookie validation. |
UsePkce |
true |
|
ResponseMode |
query |
Together with Lax correlation/nonce cookies works on plain http://localhost dev hosts. |
CorrelationAndNonceCookieSameSite |
Lax |
|
ConfigureScheme |
– | Action<string, OpenIdConnectOptions> last-chance hook per scheme. |
WebAppAuthClientOptions (client):
| Option | Default | Notes |
|---|---|---|
TokenEndpoint |
auth/token |
Relative to the host base address. Must match the server's EndpointPrefix. |
MeEndpoint |
auth/me |
Auth state endpoint; must match the server prefix as well. |
LoginPath |
/ |
Target on 401 from the token endpoint and for RedirectToLogin. Must match the server's cookie LoginPath / login page. |
ReturnUrlParameterName |
returnUrl |
|
MinimumRemainingTokenLifetime |
30 s | Client-side cache threshold. |
RedirectOnUnauthorized |
true |
Session lifetime, refresh and known limits
- The cookie's
ExpireTimeSpanis only an upper bound. The effective session length is decided by the identity provider (SSO Session Idle / Max): once the IdP refuses the refresh, the cookie is rejected and the next protected request challenges again — a silent re-login while the SSO session is alive, otherwise a real login. Recommendation: align the cookie lifetime with the IdP's "SSO Session Max". - Claims staleness. The refresh re-syncs roles and
AccessTokenClaimTypesfrom the new access token, but not the other claims in the cookie ticket (dotnet/aspnetcore#58826); role / profile changes take effect at the next real login. - Distributed lock. Refreshes are serialized per user with
IRefreshLock; the defaultInProcessRefreshLockguards one process. If the realm rotates refresh tokens ("Revoke Refresh Token") and the app runs with more than one replica, register a distributed implementation (services.AddSingleton<IRefreshLock, MyRedisRefreshLock>()beforeAddOidcWebAppAuthentication). - Cookie size / reverse proxy. Tokens live in the (chunked) cookie ticket. Behind ingress-nginx set
proxy-buffer-size: 32kandproxy-buffers-number: 4, otherwise the OIDC callback answers 502. Alternatively move the ticket into anITicketStore(CookieAuthenticationOptions.SessionStore). - Replicas need a shared data protection key ring (
SetApplicationName+PersistKeysToFileSystem/Redis), otherwise users get logged out at random. - If the consumer sets
CookieAuthenticationOptions.EventsType, the package's event hooks (OnRedirectToLogin→ 401 for the token path,OnValidatePrincipalrefresh) are bypassed — wire them into your events class instead.
Migration 4.x → 5.0
SoftwareDriven.Authentication.OpenIdConnect.Clientis gone (AuthenticationService,ITokenStorage,LocalStorageTokenStore,TokenAuthenticationStateProvider,RefreshTokenService,AddOpenIdConnect(...)for WASM,AccessTokenRevoker). Blazor WASM apps that keep the oldRemoteAuthenticatorViewflow stay on 4.x; new / migrated apps use the Blazor Web App stack (WebApp.Server+WebApp.Client).Shared:ConfigurationExtensions.GetOidcClientOptions()removed (with theIdentityModel.OidcClientdependency);AccessTokenResponseadded.Server:ClaimsTransformerKeycloak.ConfigureRoleClaim(...)(static) removed →services.Configure<KeycloakClaimsTransformationOptions>(...).AddOpenIdConnect(...)got an optionalAction<JwtBearerOptions>hook.IdentityModelEventSource.ShowPIIis no longer forced on.ApiKey.Server:ApiKeyAuthenticationOptions.AuthenticationTypeis a property now; handler constructor withoutISystemClock.- All packages: net10.0, version 5.0.0, framework reference
Microsoft.AspNetCore.Appinstead of individualMicrosoft.AspNetCore.*packages.
Build / pack / publish
dotnet build SoftwareDriven.Authentication.sln -c Release
dotnet pack SoftwareDriven.Authentication.sln -c Release -o .\artifacts
dotnet nuget push .\artifacts\SoftwareDriven.Authentication.*.nupkg -s https://api.nuget.org/v3/index.json -k <key>
Version and shared package metadata live in Directory.Build.props.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.