Skip to content

Identity — User Claims & Tenant Context

Granit.Identity provides a provider-agnostic identity management layer. Keycloak, Cognito, Google Cloud Identity Platform (Firebase Auth), or any OIDC provider handles authentication — Granit.Identity handles everything else: user lookup, role management, session control, password operations, and a local user cache with GDPR erasure and pseudonymization built in.

  • DirectoryGranit.Identity/ Abstractions (6 identity interfaces + unified session manager), null defaults
    • DirectoryGranit.Identity.Federated/ Federated identity abstractions
      • Granit.Identity.Federated.Keycloak Keycloak Admin API implementation (+ realm-vs-client role distinction)
      • Granit.Identity.Federated.EntraId Microsoft Graph API implementation (+ Entra ID App Role distinction)
      • Granit.Identity.Federated.Cognito AWS Cognito User Pool API implementation
      • Granit.Identity.Federated.GoogleCloud Google Cloud Identity Platform (Firebase Auth) implementation
      • Granit.Identity.Federated.EntityFrameworkCore User cache (cache-aside, login-time sync)
      • Granit.Identity.Federated.Privacy Personal-data export provider for the federated user cache
    • DirectoryGranit.Identity.Local/ Shared domain types for self-hosted identity providers
      • Granit.Identity.Local.AspNetIdentity ASP.NET Core Identity provider (LocalIdentity + role orchestration + tenant-aware lookup)
      • Granit.Identity.Local.Endpoints Account self-service endpoints (login, register, profile, 2FA, passkey) + role CRUD + admin impersonation
      • Granit.Identity.Local.Notifications 9 security notifications (welcome, password reset, 2FA, email change, impersonation)
      • Granit.Identity.Local.Privacy Personal-data export provider for the local identity store
    • Granit.Identity.Endpoints Minimal API endpoints (provider CRUD, user cache sync, sessions & devices, GDPR, webhook)
    • Granit.Identity.Notifications Opt-in IRecipientResolver bridge for the notification pipeline
PackageRoleDepends on
Granit.IdentityAbstractions, null defaults
Granit.Identity.FederatedFederated identity abstractionsGranit.Identity
Granit.Identity.Federated.KeycloakKeycloak Admin API implementation (+ realm-vs-client role distinction)Granit.Identity.Federated
Granit.Identity.Federated.EntraIdMicrosoft Graph API implementation (+ App Role boot-time sync)Granit.Identity.Federated
Granit.Identity.Federated.CognitoAWS Cognito User Pool API implementationGranit.Identity.Federated
Granit.Identity.Federated.GoogleCloudFirebase Auth implementation (Firebase Admin SDK)Granit.Identity.Federated
Granit.Authentication.JwtBearer.GoogleCloudJWT Bearer for Firebase Auth + claims transformationGranit.Authentication.JwtBearer
Granit.Identity.Federated.EntityFrameworkCoreEF Core user cacheGranit.Identity.Federated, Granit.Persistence
Granit.Identity.Federated.PrivacyPersonal-data export provider for the federated user cacheGranit.Identity.Federated, Granit.Privacy.BlobStorage
Granit.Identity.LocalShared domain types for self-hosted identity providersGranit.Identity, Granit.Events, Granit.Guids, Granit.Settings, Granit.Timing
Granit.Identity.Local.AspNetIdentityASP.NET Core Identity provider, role orchestration, tenant-aware role lookupGranit.Identity.Local, Granit.Persistence.EntityFrameworkCore
Granit.Identity.Local.EndpointsAccount self-service + role CRUD + admin impersonation REST endpointsGranit.Identity.Local, Granit.Authorization
Granit.Identity.Local.Notifications9 security email/in-app notifications (17 cultures)Granit.Identity.Local, Granit.Notifications.Abstractions, Granit.Templating
Granit.Identity.Local.PrivacyPersonal-data export provider for the local identity storeGranit.Identity.Local, Granit.Privacy.BlobStorage
Granit.Identity.EndpointsREST endpoints for user cache + provider CRUD + GDPRGranit.Identity, Granit.Authorization
Granit.Identity.NotificationsOpt-in IRecipientResolver feeding recipient contact info to the notification pipelineGranit.Identity, Granit.Notifications.Abstractions
graph TD
    I[Granit.Identity] --> C[Granit]
    IF[Granit.Identity.Federated] --> I
    IK[Granit.Identity.Federated.Keycloak] --> IF
    IEI[Granit.Identity.Federated.EntraId] --> IF
    IC[Granit.Identity.Federated.Cognito] --> IF
    IGC[Granit.Identity.Federated.GoogleCloud] --> IF
    AGC[Granit.Authentication.JwtBearer.GoogleCloud] --> JB[Granit.Authentication.JwtBearer]
    IEF[Granit.Identity.Federated.EntityFrameworkCore] --> IF
    IEF --> P[Granit.Persistence]
    IFP[Granit.Identity.Federated.Privacy] --> IF
    IFP --> PR[Granit.Privacy.BlobStorage]
    IL[Granit.Identity.Local] --> I
    IL --> S[Granit.Settings]
    ILA[Granit.Identity.Local.AspNetIdentity] --> IL
    ILE[Granit.Identity.Local.Endpoints] --> IL
    ILE --> AZ[Granit.Authorization]
    ILN[Granit.Identity.Local.Notifications] --> IL
    ILN --> N[Granit.Notifications.Abstractions]
    ILN --> T[Granit.Templating]
    ILP[Granit.Identity.Local.Privacy] --> IL
    ILP --> PR
    IE[Granit.Identity.Endpoints] --> I
    IE --> AZ
    IN[Granit.Identity.Notifications] --> I
    IN --> N
[DependsOn(typeof(GranitIdentityFederatedKeycloakModule))]
[DependsOn(typeof(GranitIdentityFederatedEntityFrameworkCoreModule))]
[DependsOn(typeof(GranitIdentityEndpointsModule))]
public class AppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddGranitIdentityEntityFrameworkCore<AppDbContext>();
context.Services.AddGranitIdentityEndpoints();
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
context.App.MapGranitIdentityUserCache();
}
}

IIdentityProvider is a composite interface built from 6 fine-grained interfaces following ISP (Interface Segregation Principle). Inject only what you need:

InterfaceMethodsPurpose
IIdentityUserReaderGetUsersAsync, GetUserAsyncRead user profiles
IIdentityUserWriterCreateUserAsync, UpdateUserAsync, SetUserEnabledAsyncMutate user profiles
IIdentityRoleManagerGetRolesAsync, GetUserRolesAsync, AssignRoleAsync, RemoveRoleAsync, GetRoleMembersAsyncRole assignments
IIdentityGroupManagerGetGroupsAsync, GetUserGroupsAsync, AddUserToGroupAsync, RemoveUserFromGroupAsyncGroup membership
IIdentityPasswordManagerGetPasswordChangedAtAsync, SendPasswordResetEmailAsync, SetTemporaryPasswordAsyncPassword operations
IIdentityCredentialVerifierVerifyUserCredentialsAsyncCredential validation
// Inject only what you need — not the full IIdentityProvider
public class UserProfileService(IIdentityUserReader userReader)
{
public async Task<IdentityUser?> GetProfileAsync(
string userId, CancellationToken cancellationToken)
{
return await userReader.GetUserAsync(userId, cancellationToken)
.ConfigureAwait(false);
}
}

Not all providers support every operation. Query capabilities at runtime:

public class GroupService(
IIdentityGroupManager groups,
IIdentityProviderCapabilities capabilities)
{
public async Task PromoteToHierarchyAsync(
string userId, string groupId, CancellationToken cancellationToken)
{
if (!capabilities.SupportsGroupHierarchy)
{
throw new BusinessException(
"Identity:UnsupportedOperation",
"Provider does not support nested groups");
}
await groups.AddUserToGroupAsync(userId, groupId, cancellationToken)
.ConfigureAwait(false);
}
}

Listing a user’s active sessions, revoking them, and inspecting their devices is a single, topology-agnostic APIIUserSessionManager. It is the one orchestrator every backend funnels through; the backend only contributes mechanism (where sessions live, how to revoke them) through an IUserSessionProvider / IUserDeviceProvider adapter, never policy.

public interface IUserSessionManager
{
// Caller's own sessions, enriched with geolocation + persisted risk (computed once).
Task<IReadOnlyList<UserSessionView>> ListAsync(
string userId, string? currentSessionId, CancellationToken cancellationToken = default);
Task<bool> RevokeAsync(string userId, string sessionId, CancellationToken cancellationToken = default);
Task<int> RevokeOthersAsync(string userId, string currentSessionId, CancellationToken cancellationToken = default);
Task<int> RevokeAllAsync(string userId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<UserDevice>> ListDevicesAsync(string userId, CancellationToken cancellationToken = default);
}

The manager owns the cross-cutting concerns — authorization, audit, geolocation, and risk enrichment are applied exactly once, regardless of backend. Each topology ships one adapter:

AdapterPackageBacks
BffUserSessionProviderGranit.Bff.UserSessionsCookie sessions in the BFF token store
OpenIddictUserSessionProviderGranit.OpenIddictOpenIddict refresh tokens (one valid refresh token = one session)
Keycloak / Cognito / Entra ID providerGranit.Identity.Federated.*The provider’s own session/device APIs

Firebase Auth ships no session adapter, so the endpoints return an empty list for Google Cloud deployments.

Granit.Identity.Endpoints exposes two route families over the same manager:

RouteMethodAuthorizationPurpose
/sessionsGETAuthenticated callerList the caller’s own sessions
/sessions/{sessionId}DELETEAuthenticated callerRevoke one of the caller’s sessions
/sessionsDELETEAuthenticated callerRevoke all the caller’s sessions except the current one
/devicesGETAuthenticated callerList the caller’s own devices (each carries isTrusted / trustedUntil)
/devices/trustPOSTAuthenticated callerMark the caller’s current device as trusted
/devices/{deviceId}/trustDELETEAuthenticated callerRevoke trust for one of the caller’s devices
/identity/provider/users/{userId}/sessionsGETIdentity.Sessions.ReadList another user’s sessions (admin)
/identity/provider/users/{userId}/sessions/{sessionId}DELETEIdentity.Sessions.ManageTerminate one session (admin)
/identity/provider/users/{userId}/sessionsDELETEIdentity.Sessions.ManageTerminate all sessions (admin)
/identity/provider/users/{userId}/devicesGETIdentity.Sessions.ReadList another user’s devices (admin)

The canonical /sessions + /devices surface is self-service: it requires only an authenticated caller and always scopes to that caller’s own subject — there is no /bff/sessions variant and no provider-specific read path anymore. Map it with MapGranitUserSessions() (route prefix optional); the admin family rides on MapGranitIdentityProvider() and is permission-gated per authz convention.

app.MapGranitUserSessions(); // → /sessions, /devices
app.MapGranitIdentityProvider(); // → /identity/provider/users/{userId}/sessions|devices

“Remember this device” lets a user vouch for the browser they routinely sign in from, so the framework can reduce sign-in friction on that device without weakening security everywhere else. The anchor is a signed device cookie bound to a stable device identity, backed by a durable trust store; a recorded trust verdict then feeds two independent decisions — the two-factor step-up at login and the session-risk signal.

public enum DeviceTrustLevel
{
None = 0, // no trust recorded — the device is treated as new/unknown
Remembered = 1, // signed, revocable "remember this device" cookie (time-bounded)
Strong = 2, // device-bound credential (passkey / WebAuthn) — strongest signal
}

Marking a device through POST /devices/trust records Remembered — trust derived from a data-protected cookie, spoofable only by exfiltrating the token. Strong is reserved for trust backed by a device-bound passkey (the credential cannot leave the authenticator); wiring passkey assertions to Strong is a planned follow-up, so today the manage endpoint always grants Remembered.

The trust endpoints ride on the canonical /devices surface — self-service, scoped to the authenticated caller, mapped by MapGranitUserSessions():

RouteMethodBody / resultPurpose
/devices/trustPOST{ deviceId, trustedUntil }Bind the current browser to a device identity and record a trust verdict for TrustDuration
/devices/{deviceId}/trustDELETE204 No ContentRemove the trust verdict; if it is the current device, clear the cookie too

Listing devices (GET /devices) now enriches each UserDeviceResponse with isTrusted and trustedUntil, read back from the trust store by the manager.

{
"Identity": {
"DeviceTrust": {
"TrustDuration": "30.00:00:00",
"AllowMfaBypass": false,
"CookieName": "__Host-id-devicetrust"
}
}
}
KeyTypeDefaultDescription
Identity:DeviceTrust:TrustDurationTimeSpan30.00:00:00 (30 d)Lifetime carried by both the signed cookie and the stored verdict
Identity:DeviceTrust:AllowMfaBypassboolfalseWhether a trusted device may skip the 2FA step-up (see below)
Identity:DeviceTrust:CookieNamestring__Host-id-devicetrustDevice-cookie name; the __Host- prefix requires HTTPS + Path=/ + no Domain (override for plain-HTTP dev)

The manage endpoints and the login step-up bypass work out of the box once AddGranitIdentityEndpoints() is registered. To also feed the federated risk signal, add the resolution middleware so the device binding is decoded once per request and stamped onto the session-created event:

app.UseGranitDeviceTrust(); // decode the device cookie into HttpContext.Items, before endpoints

UseGranitDeviceTrust() is optional: when absent, trust still drives the manage endpoints and the step-up bypass, but sessions are treated as coming from an untrusted device for risk scoring (the signal degrades safely, never fails).

When AllowMfaBypass is enabled, a login from a current device that is actively trusted completes without the two-factor challenge; otherwise the challenge proceeds as usual.

An actively-trusted device dampens anomaly noise without masking real threats:

Anomaly levelOn a trusted device
HighPreserved — a trusted device can still be compromised
MediumDowngraded to Low and the suspicious-session alert is suppressed (reason trusted_device recorded)
Low / NoneUnchanged

Federated topologies benefit only when the browser actually traverses Granit — a BFF or OpenIddict front-channel sign-in carries the device cookie, so the resolved DeviceId reaches the risk evaluator. A pure IdP webhook session (server-to-server, no browser cookie) is treated as untrusted by design. See User Sessions for the evaluator detail.

Granit.Identity.Abstractions registers an in-memory, single-node IDeviceTrustStore — fine for development. Installing Granit.Identity.EntityFrameworkCore swaps in a durable store backed by the identity_device_trusts table (unique on (UserId, DeviceId)), mapped into the same IdentityDbContext as the user-session risk store — one DbContext, one set of migrations, no separate database to provision. The application owns the migration.

public interface IDeviceTrustStore
{
Task SetAsync(string userId, string deviceId, DeviceTrustVerdict verdict, CancellationToken ct = default);
Task<DeviceTrustVerdict?> GetAsync(string userId, string deviceId, CancellationToken ct = default);
Task<IReadOnlyDictionary<string, DeviceTrustVerdict>> GetManyAsync(
string userId, IReadOnlyCollection<string> deviceIds, CancellationToken ct = default);
Task RevokeAsync(string userId, string deviceId, CancellationToken ct = default);
}
sequenceDiagram
    participant U as User / browser
    participant API as /devices/trust
    participant Cookie as IDeviceTrustCookieService
    participant Store as IDeviceTrustStore
    participant Login as Login pipeline

    U->>API: POST /devices/trust
    API->>Cookie: IssueDeviceCookieAsync(user)
    Cookie-->>U: Set-Cookie __Host-id-devicetrust (signed)
    API->>Store: SetAsync(user, deviceId, Remembered, trustedUntil)
    Note over U,Login: later sign-in from the same browser
    U->>Login: authenticate (2FA required)
    Login->>Cookie: ResolveDeviceId(user)
    Login->>Store: GetAsync(user, deviceId) → active?
    alt trusted & AllowMfaBypass
        Login-->>U: signed in, 2FA skipped
    else
        Login-->>U: 2FA challenge
    end

Building on IIdentityUserReader, the opt-in Granit.Identity.Notifications bridge is the recommended way to make the notification pipeline deliverable without hand-rolling an IRecipientResolver. A single IdentityRecipientResolver adapter feeds recipient Email, DisplayName, PhoneNumber, and PreferredCulture from the identity layer — local (OpenIddict) and every federated provider alike, reading through the user cache so there is no extra IdP round-trip per notification.

[DependsOn(typeof(GranitIdentityNotificationsModule))]
public class AppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddGranitIdentityRecipientResolver();
}
}

It registers with TryAddScoped, so an application-supplied IRecipientResolver always wins. See Notification channels for the field mapping and the Identity:RecipientResolver options.

Granit.Identity ships a canonical User aggregate that is the single anchor for audit fields, ownership, and tenant assignment across every Granit module. It is not the same row as the identity-provider credentials — those live on LocalIdentity (Granit.Identity.Local, ASP.NET Identity-backed) or on a UserCacheEntry mirror of an external IdP. The canonical User is the join point that lets the rest of the framework talk about people without caring which provider authenticates them.

Per ADR-051 and PR #1712, a canonical User row is auto-created whenever a LocalIdentity is created (B-step 2.5). The same handler runs after federated cache upserts so every authenticated principal has exactly one canonical User per tenant, with stable Id, Email, and audit fields the rest of the framework can reference.

Self-registered users — both local (POST /api/account/register) and external (sign in with Google / Microsoft / GitHub) — can be granted a default role automatically. A single opt-in setting, Identity.Local.DefaultUserRole (default empty), names the role; a shared AssignDefaultRoleHandler subscribes to UserRegisteredEto — published by both registration paths — so the two flows provision roles symmetrically through one handler.

The assignment is idempotent, tenant-scoped, and non-blocking: it resolves the role inside the registering user’s tenant scope (so a per-tenant override wins under SchemaPerTenant — see ADR-023), and if the configured role does not exist, registration still succeeds but the user receives no role (logged at Warning, never thrown). That is the secure-by-default outcome: a misconfigured role grants nothing rather than breaking sign-up or over-granting.

See ADR-066 for the full decision, and the account API for the setting and the external profile-completion flow.

Granit.Identity.Federated.EntityFrameworkCore maintains a local cache of user data from the identity provider. This avoids hitting Keycloak on every “who is this user?” query.

public interface IUserLookupService
{
Task<UserCacheEntry?> FindByIdAsync(string userId, CancellationToken cancellationToken);
Task<IReadOnlyList<UserCacheEntry>> FindByIdsAsync(
IReadOnlyCollection<string> userIds, CancellationToken cancellationToken);
Task<PagedResult<UserCacheEntry>> SearchAsync(
string searchTerm, int page, int pageSize, CancellationToken cancellationToken);
Task RefreshByIdAsync(string userId, CancellationToken cancellationToken);
Task RefreshAllAsync(CancellationToken cancellationToken);
Task RefreshStaleAsync(CancellationToken cancellationToken);
Task DeleteByIdAsync(string userId, CancellationToken cancellationToken);
Task PseudonymizeByIdAsync(string userId, CancellationToken cancellationToken);
}
sequenceDiagram
    participant App
    participant Cache as UserLookupService
    participant DB as EF Core
    participant IDP as Keycloak

    App->>Cache: FindByIdAsync("user-123")
    Cache->>DB: SELECT WHERE ExternalUserId = "user-123"
    alt Cache hit (fresh)
        DB-->>Cache: UserCacheEntry
        Cache-->>App: UserCacheEntry
    else Cache miss or stale
        Cache->>IDP: GetUserAsync("user-123")
        IDP-->>Cache: IdentityUser
        Cache->>DB: INSERT/UPDATE UserCacheEntry
        Cache-->>App: UserCacheEntry
    end
public class UserCacheEntry : AuditedEntity, IMultiTenant
{
public string ExternalUserId { get; set; } = string.Empty;
public string? Username { get; set; }
public string? Email { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public bool Enabled { get; set; }
public DateTimeOffset? LastSyncedAt { get; set; }
public Guid? TenantId { get; set; }
}

UserCacheSyncMiddleware automatically refreshes the cache entry when a user authenticates. This ensures the cache stays fresh without polling.

{
"Identity:Federated:UserCache": {
"StalenessThreshold": "1.00:00:00",
"EnableLoginTimeSync": true,
"IncrementalSyncBatchSize": 50
}
}
PropertyDefaultDescription
StalenessThreshold24:00:00Age after which a cache entry is considered stale
EnableLoginTimeSynctrueRefresh cache on login
IncrementalSyncBatchSize50Batch size for RefreshStaleAsync

Granit.Identity.Federated.Keycloak implements the full IIdentityProvider against the Keycloak Admin REST API.

{
"Identity:Federated:Keycloak": {
"BaseUrl": "https://keycloak.example.com",
"Realm": "my-realm",
"ClientId": "admin-cli",
"ClientSecret": "secret",
"TimeoutSeconds": 30,
"UseTokenExchangeForDeviceActivity": false,
"DirectAccessClientId": "direct-access-client"
}
}

Required Keycloak service account roles:

  • realm-management:view-users
  • realm-management:manage-users
builder.Services.AddHealthChecks()
.AddGranitKeycloakHealthCheck();

Verifies Keycloak connectivity by requesting a client_credentials token. Tagged ["readiness", "startup"]. Returns Unhealthy on 401/403, Degraded on 5xx.

All Keycloak Admin API calls are traced via IdentityKeycloakActivitySource. Activity names follow the pattern Granit.Identity.Keycloak.{Operation}.

Granit.Identity.Federated.Cognito implements the full IIdentityProvider against the AWS Cognito User Pool API via AWSSDK.CognitoIdentityProvider.

{
"Identity:Federated:Cognito": {
"UserPoolId": "eu-west-1_XXXXXXXXX",
"Region": "eu-west-1",
"AccessKeyId": "",
"SecretAccessKey": "",
"TimeoutSeconds": 30
}
}

AccessKeyId and SecretAccessKey are optional — when omitted, the SDK uses the default credential chain (IAM role, environment variables, ~/.aws/credentials).

  • Groups as roles — Cognito has no native “roles” concept. Groups serve as both roles and groups. GetRolesAsync and GetGroupsAsync return the same data.
  • No individual session termination — Cognito supports AdminUserGlobalSignOut (terminate all sessions) but not individual session termination. IIdentityProviderCapabilities.SupportsIndividualSessionTermination returns false.
  • No device tracking — Cognito exposes no per-device data, so the /devices endpoint returns an empty list for Cognito deployments.

All Cognito User Pool API calls are traced via IdentityCognitoActivitySource. Activity names follow the pattern cognito.{operation}.

Granit.Identity.Federated.GoogleCloud implements the full IIdentityProvider against the Firebase Auth API via Firebase Admin SDK v3.

{
"Identity": {
"GoogleCloud": {
"ProjectId": "my-firebase-project",
"RolesClaimKey": "roles",
"TimeoutSeconds": 30
}
}
}

For Workload Identity (recommended in GKE), omit CredentialFilePath — Application Default Credentials are used automatically.

  • Roles via custom claims — Firebase Auth has no native roles. User roles are stored as a JSON array in custom claims (configurable key, default "roles"). GetRolesAsync returns an empty list; GetUserRolesAsync extracts from claims.
  • Groups not supported — Firebase Auth does not support groups. AddUserToGroupAsync and RemoveUserFromGroupAsync throw NotSupportedException.
  • No individual session termination — Firebase supports RevokeRefreshTokens (terminate all sessions) but not individual session termination. IIdentityProviderCapabilities.SupportsIndividualSessionTermination returns false.
  • Credential verificationVerifyUserCredentialsAsync validates email/password via the Firebase Auth REST API.
builder.Services.AddHealthChecks()
.AddGranitGoogleCloudIdentityHealthCheck();

Verifies that ProjectId is configured. Tagged ["readiness"].

All Firebase Auth API calls are traced via IdentityGoogleCloudActivitySource. Activity names follow the pattern firebase.{operation}.

Granit.Authentication.JwtBearer.GoogleCloud configures JWT Bearer authentication for Firebase Auth tokens and transforms custom claims into standard ClaimTypes.Role.

{
"Authentication:GoogleCloud": {
"ProjectId": "my-firebase-project",
"RequireHttpsMetadata": true,
"AdminRole": "admin",
"RolesClaimKey": "roles"
}
}
PropertyDefaultDescription
ProjectIdGCP project ID (derives OIDC authority)
RequireHttpsMetadatatrueRequire HTTPS for OIDC metadata discovery
AdminRole"admin"Role name for the “Admin” authorization policy
RolesClaimKey"roles"Custom claims key containing roles

The module derives the OIDC authority as https://securetoken.google.com/{ProjectId} and configures JWT Bearer validation with Audience = ProjectId, NameClaimType = "email".

GoogleCloudClaimsTransformation maps Firebase custom claims to ClaimTypes.Role. Supports JSON array format (["admin","editor"]) and single string values. Existing ClaimTypes.Role claims are preserved without duplication.

Granit.Identity.Endpoints exposes user cache management as Minimal API endpoints.

MethodRoutePermissionDescription
GET/identity/users/searchIdentity.UserCache.ReadSearch cached users
GET/identity/users/{id}Identity.UserCache.ReadGet by external ID
POST/identity/users/batchIdentity.UserCache.ReadBatch resolve by IDs
POST/identity/users/syncIdentity.UserCache.SyncSync specific user
POST/identity/users/sync-allIdentity.UserCache.SyncFull sync from provider
DELETE/identity/users/{id}Identity.UserCache.DeleteGDPR erase
PATCH/identity/users/{id}/pseudonymizeIdentity.UserCache.DeleteGDPR pseudonymize
GET/identity/users/statsIdentity.UserCache.ReadCache statistics
GET/identity/users/capabilitiesIdentity.UserCache.ReadProvider capabilities
POST/identity/webhook(anonymous, signature-validated)IdP webhook receiver

The webhook endpoint receives events from the identity provider and reacts to each:

EventTypeReaction
user_created / user_updatedRefresh the user cache entry
user_deletedDrop the user cache entry
loginPublish a UserSessionCreatedEto so anomaly detection runs off the login critical path

Payload authenticity is verified via HMAC-SHA256 over "{unix-timestamp}.{body}", presented in a Stripe-style header (default X-Webhook-Signature: t=<unix-seconds>,v1=<hex-digest>) and compared in constant time. Stale timestamps are rejected by a configurable replay window (default 5 minutes). The secret, header name, and window live on IdentityWebhookOptions (Identity:Webhook).

Keycloak login events (SPI event-listener)

Section titled “Keycloak login events (SPI event-listener)”

Keycloak does not call the API on login, so it never sees session-creation directly. To feed suspicious-session detection from a Keycloak topology, deploy the Keycloak SPI event-listener (shipped with #2690): it subscribes to LOGIN events and POSTs them to /identity/webhook as a login payload (UserId, the Keycloak sid as SessionId, plus IpAddress and UserAgent), signed with the shared HMAC secret.

{
"Identity": {
"Webhook": {
"Secret": "<shared-with-the-keycloak-spi>",
"SignatureHeaderName": "X-Webhook-Signature",
"ReplayWindow": "00:05:00"
}
}
}

Configure the matching secret on the Keycloak side so the SPI signs with the same key. The receiver turns each login into a UserSessionCreatedEto (source Keycloak), decoupling anomaly detection from the login path — the raw IP stays server-side for geolocation.

Two operations support data subject rights:

// Right to erasure (Art. 17) — hard delete
await userLookupService.DeleteByIdAsync("user-123", cancellationToken)
.ConfigureAwait(false);
// Right to restriction (Art. 18) — pseudonymize
await userLookupService.PseudonymizeByIdAsync("user-123", cancellationToken)
.ConfigureAwait(false);
  • Delete removes the UserCacheEntry entirely (physical delete, not soft delete)
  • Pseudonymize replaces PII fields with anonymized values while preserving the record

Both operations emit Wolverine domain events (IdentityUserDeletedEvent, IdentityUserUpdatedEvent) for downstream modules to react.

Granit.Identity.Local.Notifications provides 9 out-of-the-box email and in-app notifications for local identity management. Wolverine event handlers listen to identity lifecycle events and dispatch notifications via Granit.Notifications.

NotificationChannelsOpt-outTrigger
Security.WelcomeEmailYesUser registration
Security.PasswordResetEmailNoForgot password flow
Security.EmailConfirmationEmailNoRegistration / resend
Security.PasswordChangedEmailNoPassword change (compromise alert)
Security.AccountLockedEmail, InAppNoFailed login attempts (includes password reset link for self-service unlock)
Security.TwoFactorChangedEmailNo2FA enabled/disabled
Security.EmailChangeAlertEmailNoEmail change alert (old address)
Security.EmailChangeConfirmationEmailNoEmail change confirm (new address)
Security.ImpersonationAlertEmail, InAppNoAdmin impersonation (GDPR/SOC2)
[DependsOn(typeof(GranitIdentityLocalNotificationsModule))]
public class AppModule : GranitModule { }
{
"Identity": {
"Notifications": {
"FrontendBaseUrl": "https://app.example.com",
"ResetPasswordPath": "reset-password",
"ConfirmEmailPath": "confirm-email",
"ChangeEmailPath": "confirm-email-change"
}
}
}

Templates are embedded in the package (9 types x 18 cultures) and wrapped by Layout.Email via the layout registry. Tenants can override any template or the layout via the database template store.

The Security.AccountLocked notification is sent when a user’s account is locked after exceeding the maximum number of failed login attempts. The email includes:

  • The number of failed attempts that triggered the lockout
  • The exact lockout expiry date (formatted per the user’s locale via Scriban)
  • A “Reset my password” button with a one-time reset token

Resetting the password via this link immediately unlocks the account and resets the exponential backoff counter — the user never needs to wait or contact support.

See Account lockout for the complete lockout security architecture.

Email change triggers two notifications from a single EmailChangeRequestedEto event:

  1. Security.EmailChangeAlert → sent to the current email (security alert)
  2. Security.EmailChangeConfirmation → sent to the new email (uses RecipientOverride)

The confirmation token is generated by ASP.NET Core Identity and encodes the new email — no additional database column is needed.

Granit.Identity.Federated.Notifications ships notifications surfacing federated-provider lifecycle events (Keycloak / Entra ID / Cognito / Firebase). Tenants opt in via the notifications admin UI.

NotificationTriggerChannelsSeverity
identity.sync_failedIdentityUserSyncFailedEto — login-time user-cache sync from the federated provider failedEmail, InAppWarning
identity.token_exchange_auditIdentityTokenExchangedEto — token exchange completed (audit trail of who acted on whose behalf)Email, InAppWarning
identity.user_provisioning_removedIdentityUserDeletedEto — federated user removed from the provider; cache entry deprovisionedEmail, InAppInfo

Email templates ship in EN + FR; additional cultures are produced via the translation script (US #1311).

CategoryKey typesPackage
ModuleGranitIdentityModule, GranitIdentityFederatedModule, GranitIdentityFederatedKeycloakModule, GranitIdentityFederatedCognitoModule, GranitIdentityFederatedGoogleCloudModule, GranitAuthenticationJwtBearerGoogleCloudModule, GranitIdentityFederatedEntityFrameworkCoreModule, GranitIdentityEndpointsModule, GranitIdentityLocalAspNetIdentityModule
AbstractionsIIdentityProvider, IIdentityUserReader, IIdentityUserWriter, IIdentityRoleManager, IIdentityGroupManager, IIdentityPasswordManager, IIdentityCredentialVerifierGranit.Identity
SessionsIUserSessionManager, IUserSessionProvider, IUserDeviceProviderGranit.Identity
Device trustIDeviceTrustStore, DeviceTrustVerdict, DeviceTrustLevel, IDeviceTrustCookieService, DeviceTrustOptions, UseGranitDeviceTrust()Granit.Identity · Granit.Identity.Endpoints
LookupIUserLookupService, IUserCacheStats, IIdentityProviderCapabilitiesGranit.Identity
ModelsIdentityUser, IdentityUserCreate, IdentityUserUpdate, IdentityRole, IdentityGroup, UserSessionDescriptor, UserSessionView, UserDeviceGranit.Identity
KeycloakKeycloakIdentityProvider, KeycloakAdminOptionsGranit.Identity.Federated.Keycloak
CognitoCognitoIdentityProvider, CognitoAdminOptionsGranit.Identity.Federated.Cognito
Google CloudGoogleCloudIdentityProvider, GoogleCloudIdentityOptionsGranit.Identity.Federated.GoogleCloud
Google Cloud AuthGoogleCloudClaimsTransformation, GoogleCloudAuthenticationOptionsGranit.Authentication.JwtBearer.GoogleCloud
EF CoreUserCacheEntry, IUserCacheDbContext, UserCacheOptionsGranit.Identity.Federated.EntityFrameworkCore
EndpointsIdentityEndpointsOptions, IdentityWebhookOptionsGranit.Identity.Endpoints
NotificationsWelcomeNotificationType, PasswordResetNotificationType, EmailConfirmationNotificationType, PasswordChangedNotificationType, AccountLockedNotificationType, TwoFactorChangedNotificationType, EmailChangeAlertNotificationType, EmailChangeConfirmationNotificationType, ImpersonationAlertNotificationTypeGranit.Identity.Local.Notifications
Notifications optionsIdentityNotificationOptionsGranit.Identity.Local.Notifications
Recipient resolverGranitIdentityNotificationsModule, IdentityRecipientResolverOptions, AddGranitIdentityRecipientResolver()Granit.Identity.Notifications
EventsPasswordChangedEto, AccountLockedEto (includes LockoutEndUtc + ResetToken), TwoFactorChangedEto, EmailConfirmationRequestedEto, EmailChangeRequestedEtoGranit.Identity.Local
ExtensionsAddGranitIdentity(), AddIdentityProvider<T>(), AddGranitIdentityKeycloak(), AddGranitIdentityCognito(), AddGranitIdentityGoogleCloud(), AddGranitGoogleCloudAuthentication(), AddGranitIdentityEntityFrameworkCore<T>(), AddGranitIdentityEndpoints(), MapGranitIdentityUserCache(), MapGranitUserSessions(), MapGranitIdentityProvider()