Authentication — Keycloak, EntraID & JWT
Why pluggable authentication?
Section titled “Why pluggable authentication?”Every identity provider (Keycloak, Entra ID, Cognito) uses a different JWT structure — different claim names for roles, different group mappings, different logout protocols. Without an abstraction layer, switching providers means rewriting authentication code across your entire application. In regulated environments (healthcare, finance), this also means a new security audit every time.
Granit.Authentication normalizes all of this behind a single module interface: swap
GranitAuthenticationJwtBearerKeycloakModule for GranitAuthenticationJwtBearerEntraIdModule and your
endpoints, authorization policies, and audit logs continue to work unchanged. Back-channel
logout is built in — when a session is revoked at the identity provider, your API
rejects the token immediately instead of waiting for expiry.
Package structure
Section titled “Package structure”DirectoryGranit.Authentication.JwtBearer/ Generic JWT Bearer, back-channel logout
- Granit.Authentication.JwtBearer.Keycloak Keycloak claims transformation
- Granit.Authentication.JwtBearer.EntraId Entra ID roles parsing
- Granit.Authentication.JwtBearer.Cognito Cognito groups → roles
- Granit.Authentication.JwtBearer.GoogleCloud Google Cloud Identity Platform claims
DirectoryGranit.Authentication.OpenIddict/ Built-in token validation (OpenIddict as IdP)
- …
DirectoryGranit.Oidc/ OIDC client primitives (discovery, requests, DPoP, PKCE)
- …
DirectoryGranit.Oidc.TokenManagement/ Token lifecycle (client credentials, caching, HttpClient)
- …
DirectoryGranit.Authentication.DPoP/ DPoP proof validation (any IdP)
- …
| Package | Role | Depends on |
|---|---|---|
Granit.Authentication.JwtBearer | JWT Bearer middleware, back-channel logout | Granit.Users |
Granit.Authentication.JwtBearer.Keycloak | Keycloak claims transformation | Granit.Authentication.JwtBearer |
Granit.Authentication.JwtBearer.EntraId | Entra ID roles parsing | Granit.Authentication.JwtBearer |
Granit.Authentication.JwtBearer.Cognito | Cognito groups → roles | Granit.Authentication.JwtBearer |
Granit.Authentication.JwtBearer.GoogleCloud | Google Cloud Identity Platform claims | Granit.Authentication.JwtBearer |
Granit.Authentication.OpenIddict | Built-in token validation for OpenIddict IdP | Granit.Users |
Granit.Oidc | OIDC client primitives — discovery, typed requests, DPoP proof generation, PKCE | Granit, Granit.Timing |
Granit.Oidc.TokenManagement | Token lifecycle — client credentials caching, DelegatingHandler, revocation | Granit.Oidc |
Granit.Authentication.DPoP | IdP-agnostic DPoP proof validation (RFC 9449) | Granit, Granit.Timing |
[DependsOn(typeof(GranitAuthenticationJwtBearerKeycloakModule))]public class AppModule : GranitModule { }{ "Authentication": { "Authority": "https://keycloak.example.com/realms/my-realm", "Audience": "my-client" }, "Keycloak": { "ClientId": "my-client", "AdminRole": "admin", "RoleClaimsSource": "realm_access" }}[DependsOn(typeof(GranitAuthenticationJwtBearerEntraIdModule))]public class AppModule : GranitModule { }{ "Authentication": { "Authority": "https://login.microsoftonline.com/{tenant-id}/v2.0", "Audience": "api://{client-id}" }}[DependsOn(typeof(GranitAuthenticationJwtBearerCognitoModule))]public class AppModule : GranitModule { }{ "Authentication": { "Authority": "https://cognito-idp.{region}.amazonaws.com/{userPoolId}", "Audience": "{clientId}" }, "Cognito": { "UserPoolId": "eu-west-1_XXXXXXXXX", "ClientId": "my-client-id", "Region": "eu-west-1" }}[DependsOn(typeof(GranitAuthenticationJwtBearerGoogleCloudModule))]public class AppModule : GranitModule { }{ "Authentication": { "Authority": "https://securetoken.google.com/{project-id}", "Audience": "{project-id}" }, "GoogleCloud": { "ProjectId": "my-gcp-project" }}The module validates JWTs issued by Firebase Auth / Google Cloud Identity Platform and
extracts custom claims into standard ClaimTypes.Role. See
Identity for full Google Cloud identity configuration.
When the API is behind an OpenIddict-based identity server (see the OpenIddict module), use the built-in token validation package. It leverages OpenIddict’s native validation handler instead of generic JWT Bearer, supporting both self-contained JWTs and reference tokens.
[DependsOn(typeof(GranitAuthenticationOpenIddictModule))]public class AppModule : GranitModule { }{ "Authentication": { "OpenIddict": { "Issuer": "https://auth.example.com", "Audience": "my-api" } }}[DependsOn(typeof(GranitAuthenticationJwtBearerModule))]public class AppModule : GranitModule { }{ "Authentication": { "Authority": "https://idp.example.com", "Audience": "my-api", "RequireHttpsMetadata": true, "NameClaimType": "sub" }}JWT Bearer
Section titled “JWT Bearer”GranitAuthenticationJwtBearerModule registers:
- ASP.NET Core JWT Bearer authentication
CurrentUserService—ICurrentUserServiceimplementation extracting claims fromHttpContextIRevokedSessionStore— distributed cache-backed session revocation
Configuration
Section titled “Configuration”{ "Authentication": { "Authority": "https://idp.example.com/realms/my-realm", "Audience": "my-client", "RequireHttpsMetadata": true, "NameClaimType": "sub", "BackChannelLogout": { "Enabled": true, "EndpointPath": "/auth/back-channel-logout", "SessionRevocationTtl": "01:00:00" } }}| Property | Default | Description |
|---|---|---|
Authority | — | OIDC issuer URL (required) |
Audience | — | Expected aud claim (required) |
RequireHttpsMetadata | true | Enforce HTTPS for metadata endpoint |
NameClaimType | "sub" | Claim used as user identifier |
BackChannelLogout.Enabled | false | Enable OIDC back-channel logout |
BackChannelLogout.EndpointPath | "/auth/back-channel-logout" | Endpoint path |
BackChannelLogout.SessionRevocationTtl | "01:00:00" | How long revoked sessions are remembered |
Back-channel logout
Section titled “Back-channel logout”Provider-agnostic implementation of the OIDC Back-Channel Logout specification. When the IdP sends a logout token, the session is revoked in distributed cache.
// In OnApplicationInitializationapp.MapGranitBackChannelLogout(); // POST /auth/back-channel-logout (anonymous)The endpoint validates the logout token signature against the IdP’s JWKS, extracts the sid
claim, and stores it in IDistributedCache with key granit:revoked-session:{sid}.
Subsequent requests with a revoked sid are rejected by the JWT Bearer events handler.
Keycloak claims transformation
Section titled “Keycloak claims transformation”GranitAuthenticationJwtBearerKeycloakModule post-configures JWT Bearer with Keycloak-specific behavior:
- Extracts roles from
realm_access.rolesorresource_access.{clientId}.roles - Maps them to standard
ClaimTypes.Roleclaims - Registers an
"Admin"authorization policy
// Keycloak JWT payload (simplified){ "realm_access": { "roles": ["admin", "doctor"] }, "resource_access": { "my-client": { "roles": ["manage-patients"] } }}// After transformation → ClaimTypes.Role: "admin", "doctor", "manage-patients"Entra ID claims transformation
Section titled “Entra ID claims transformation”GranitAuthenticationJwtBearerEntraIdModule post-configures JWT Bearer with Entra ID-specific behavior:
- Extracts roles from the v1.0
rolesclaim and the v2.0widsclaim - Maps them to standard
ClaimTypes.Roleclaims
Cognito claims transformation
Section titled “Cognito claims transformation”GranitAuthenticationJwtBearerCognitoModule post-configures JWT Bearer with Cognito-specific behavior:
- Extracts groups from the
cognito:groupsclaim (multiple claims with same type) - Maps them to standard
ClaimTypes.Roleclaims
// Cognito JWT payload — groups appear as repeated claims// "cognito:groups": "admin"// "cognito:groups": "doctors"// After transformation → ClaimTypes.Role: "admin", "doctors"DPoP enforcement on resource servers
Section titled “DPoP enforcement on resource servers”When using DPoP (RFC 9449)
for sender-constrained access tokens, the authorization server binds tokens to a
cryptographic key. However, resource servers must also enforce that clients present
valid DPoP proofs — otherwise an attacker could still use a stolen token with a plain
Authorization: Bearer header.
Two packages provide DPoP enforcement, depending on your identity provider:
Granit.Authentication.DPoP provides IdP-agnostic DPoP proof validation. It works with
any OIDC provider that issues cnf.jkt-bound access tokens (Keycloak 25+, Entra ID, Duende).
[DependsOn( typeof(GranitAuthenticationJwtBearerKeycloakModule), typeof(GranitAuthenticationDPoPModule))]public class MyServiceModule : GranitModule { }app.UseAuthentication();app.UseGranitDPoPValidation(); // ← validates DPoP proofsapp.UseAuthorization();{ "Authentication": { "DPoP": { "RequireDPoP": true, "AllowedAlgorithms": ["ES256", "PS256"] } }}See DPoP Resource Server Validation for the full setup guide.
Granit.Authentication.OpenIddict uses OpenIddict’s built-in ValidateProofOfPossession
handler. No extra package needed.
{ "Authentication": { "OpenIddict": { "Issuer": "https://auth.example.com", "Audience": "my-api", "RequireDPoP": true } }}Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Modules | GranitAuthenticationJwtBearerModule, GranitAuthenticationJwtBearerKeycloakModule, GranitAuthenticationJwtBearerEntraIdModule, GranitAuthenticationJwtBearerCognitoModule, GranitAuthenticationJwtBearerGoogleCloudModule, GranitAuthenticationOpenIddictModule | — |
| Abstractions | CurrentUserService, IRevokedSessionStore, BackChannelLogoutTokenValidator | Granit.Authentication.JwtBearer |
| Claims | KeycloakClaimsTransformation | Granit.Authentication.JwtBearer.Keycloak |
| Claims | EntraIdClaimsTransformation | Granit.Authentication.JwtBearer.EntraId |
| Claims | CognitoClaimsTransformation | Granit.Authentication.JwtBearer.Cognito |
| Claims | GoogleCloudClaimsTransformation | Granit.Authentication.JwtBearer.GoogleCloud |
| Options | JwtBearerAuthOptions, KeycloakOptions, CognitoOptions, GoogleCloudOptions | — |
| DPoP | IDPoPProofValidator, JwkThumbprintCalculator, DPoPValidationMiddleware | Granit.Authentication.DPoP |
| Extensions | AddGranitJwtBearer(), AddGranitKeycloak(), AddGranitCognito(), MapGranitBackChannelLogout(), UseGranitDPoPValidation() | — |
API keys (service-to-service)
Section titled “API keys (service-to-service)”For long-lived, non-interactive callers — cron jobs, partner integrations,
internal CLIs, lab equipment — JWT bearer is the wrong primitive. Use
Granit.Authentication.ApiKeys instead: opaque secrets hashed at rest with
typed prefixes, CIDR allow-listing, rotation, and a daily scanner that emails
administrators ahead of expiry. See the
API Keys page for the full lifecycle, endpoints,
permissions, configuration, and the four lifecycle notifications
(apikeys.new_key_issued, apikeys.rotation_completed, apikeys.revoked,
apikeys.expiring_soon).
See also
Section titled “See also”- API Keys — service-to-service authentication with rotation, expiry scanner, and lifecycle notifications
- DPoP Resource Server Validation — IdP-agnostic DPoP proof validation
- DPoP — Proof-of-Possession — BFF-side key generation and proof creation
- Authorization — RBAC permissions, dynamic policy provider
- Security — core abstractions (ICurrentUserService, ActorKind)
- Identity — user management, Keycloak Admin API, user cache
- OpenIddict — self-hosted OIDC server with OpenIddict