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.
Package structure
Section titled “Package structure”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
| Package | Role | Depends on |
|---|---|---|
Granit.Identity | Abstractions, null defaults | — |
Granit.Identity.Federated | Federated identity abstractions | Granit.Identity |
Granit.Identity.Federated.Keycloak | Keycloak Admin API implementation (+ realm-vs-client role distinction) | Granit.Identity.Federated |
Granit.Identity.Federated.EntraId | Microsoft Graph API implementation (+ App Role boot-time sync) | Granit.Identity.Federated |
Granit.Identity.Federated.Cognito | AWS Cognito User Pool API implementation | Granit.Identity.Federated |
Granit.Identity.Federated.GoogleCloud | Firebase Auth implementation (Firebase Admin SDK) | Granit.Identity.Federated |
Granit.Authentication.JwtBearer.GoogleCloud | JWT Bearer for Firebase Auth + claims transformation | Granit.Authentication.JwtBearer |
Granit.Identity.Federated.EntityFrameworkCore | EF Core user cache | Granit.Identity.Federated, Granit.Persistence |
Granit.Identity.Federated.Privacy | Personal-data export provider for the federated user cache | Granit.Identity.Federated, Granit.Privacy.BlobStorage |
Granit.Identity.Local | Shared domain types for self-hosted identity providers | Granit.Identity, Granit.Events, Granit.Guids, Granit.Settings, Granit.Timing |
Granit.Identity.Local.AspNetIdentity | ASP.NET Core Identity provider, role orchestration, tenant-aware role lookup | Granit.Identity.Local, Granit.Persistence.EntityFrameworkCore |
Granit.Identity.Local.Endpoints | Account self-service + role CRUD + admin impersonation REST endpoints | Granit.Identity.Local, Granit.Authorization |
Granit.Identity.Local.Notifications | 9 security email/in-app notifications (17 cultures) | Granit.Identity.Local, Granit.Notifications.Abstractions, Granit.Templating |
Granit.Identity.Local.Privacy | Personal-data export provider for the local identity store | Granit.Identity.Local, Granit.Privacy.BlobStorage |
Granit.Identity.Endpoints | REST endpoints for user cache + provider CRUD + GDPR | Granit.Identity, Granit.Authorization |
Granit.Identity.Notifications | Opt-in IRecipientResolver feeding recipient contact info to the notification pipeline | Granit.Identity, Granit.Notifications.Abstractions |
Dependency graph
Section titled “Dependency graph”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(); }}[DependsOn(typeof(GranitIdentityFederatedCognitoModule))][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(); }}[DependsOn(typeof(GranitIdentityFederatedGoogleCloudModule))][DependsOn(typeof(GranitAuthenticationJwtBearerGoogleCloudModule))][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(); }}[DependsOn(typeof(GranitIdentityModule))]public class AppModule : GranitModule { }Registers NullIdentityProvider and NullUserLookupService — useful for development
or modules that only need the interfaces.
context.Services.AddIdentityProvider<MyCustomIdentityProvider>();Interface Segregation
Section titled “Interface Segregation”IIdentityProvider is a composite interface built from 6 fine-grained interfaces
following ISP (Interface Segregation Principle). Inject only what you need:
| Interface | Methods | Purpose |
|---|---|---|
IIdentityUserReader | GetUsersAsync, GetUserAsync | Read user profiles |
IIdentityUserWriter | CreateUserAsync, UpdateUserAsync, SetUserEnabledAsync | Mutate user profiles |
IIdentityRoleManager | GetRolesAsync, GetUserRolesAsync, AssignRoleAsync, RemoveRoleAsync, GetRoleMembersAsync | Role assignments |
IIdentityGroupManager | GetGroupsAsync, GetUserGroupsAsync, AddUserToGroupAsync, RemoveUserFromGroupAsync | Group membership |
IIdentityPasswordManager | GetPasswordChangedAtAsync, SendPasswordResetEmailAsync, SetTemporaryPasswordAsync | Password operations |
IIdentityCredentialVerifier | VerifyUserCredentialsAsync | Credential validation |
// Inject only what you need — not the full IIdentityProviderpublic class UserProfileService(IIdentityUserReader userReader){ public async Task<IdentityUser?> GetProfileAsync( string userId, CancellationToken cancellationToken) { return await userReader.GetUserAsync(userId, cancellationToken) .ConfigureAwait(false); }}Provider capabilities
Section titled “Provider capabilities”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); }}User sessions & devices
Section titled “User sessions & devices”Listing a user’s active sessions, revoking them, and inspecting their devices is a
single, topology-agnostic API — IUserSessionManager. 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:
| Adapter | Package | Backs |
|---|---|---|
BffUserSessionProvider | Granit.Bff.UserSessions | Cookie sessions in the BFF token store |
OpenIddictUserSessionProvider | Granit.OpenIddict | OpenIddict refresh tokens (one valid refresh token = one session) |
| Keycloak / Cognito / Entra ID provider | Granit.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.
Endpoints
Section titled “Endpoints”Granit.Identity.Endpoints exposes two route families over the same manager:
| Route | Method | Authorization | Purpose |
|---|---|---|---|
/sessions | GET | Authenticated caller | List the caller’s own sessions |
/sessions/{sessionId} | DELETE | Authenticated caller | Revoke one of the caller’s sessions |
/sessions | DELETE | Authenticated caller | Revoke all the caller’s sessions except the current one |
/devices | GET | Authenticated caller | List the caller’s own devices (each carries isTrusted / trustedUntil) |
/devices/trust | POST | Authenticated caller | Mark the caller’s current device as trusted |
/devices/{deviceId}/trust | DELETE | Authenticated caller | Revoke trust for one of the caller’s devices |
/identity/provider/users/{userId}/sessions | GET | Identity.Sessions.Read | List another user’s sessions (admin) |
/identity/provider/users/{userId}/sessions/{sessionId} | DELETE | Identity.Sessions.Manage | Terminate one session (admin) |
/identity/provider/users/{userId}/sessions | DELETE | Identity.Sessions.Manage | Terminate all sessions (admin) |
/identity/provider/users/{userId}/devices | GET | Identity.Sessions.Read | List 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, /devicesapp.MapGranitIdentityProvider(); // → /identity/provider/users/{userId}/sessions|devicesTrusted devices
Section titled “Trusted 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.
Trust levels
Section titled “Trust levels”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.
Endpoints
Section titled “Endpoints”The trust endpoints ride on the canonical /devices surface — self-service, scoped to the
authenticated caller, mapped by MapGranitUserSessions():
| Route | Method | Body / result | Purpose |
|---|---|---|---|
/devices/trust | POST | → { deviceId, trustedUntil } | Bind the current browser to a device identity and record a trust verdict for TrustDuration |
/devices/{deviceId}/trust | DELETE | → 204 No Content | Remove 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.
Configuration
Section titled “Configuration”{ "Identity": { "DeviceTrust": { "TrustDuration": "30.00:00:00", "AllowMfaBypass": false, "CookieName": "__Host-id-devicetrust" } }}| Key | Type | Default | Description |
|---|---|---|---|
Identity:DeviceTrust:TrustDuration | TimeSpan | 30.00:00:00 (30 d) | Lifetime carried by both the signed cookie and the stored verdict |
Identity:DeviceTrust:AllowMfaBypass | bool | false | Whether a trusted device may skip the 2FA step-up (see below) |
Identity:DeviceTrust:CookieName | string | __Host-id-devicetrust | Device-cookie name; the __Host- prefix requires HTTPS + Path=/ + no Domain (override for plain-HTTP dev) |
Wiring
Section titled “Wiring”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 endpointsUseGranitDeviceTrust() 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).
Step-up bypass
Section titled “Step-up bypass”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.
Risk interplay
Section titled “Risk interplay”An actively-trusted device dampens anomaly noise without masking real threats:
| Anomaly level | On a trusted device |
|---|---|
High | Preserved — a trusted device can still be compromised |
Medium | Downgraded to Low and the suspicious-session alert is suppressed (reason trusted_device recorded) |
Low / None | Unchanged |
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.
Persistence
Section titled “Persistence”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);}Security posture
Section titled “Security posture”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
Feeding the notification pipeline
Section titled “Feeding the notification pipeline”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.
Canonical User aggregate
Section titled “Canonical User aggregate”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.
Default role provisioning
Section titled “Default role provisioning”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.
User cache (cache-aside)
Section titled “User cache (cache-aside)”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.
IUserLookupService
Section titled “IUserLookupService”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);}Cache-aside strategy
Section titled “Cache-aside strategy”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
Entity
Section titled “Entity”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; }}Login-time sync
Section titled “Login-time sync”UserCacheSyncMiddleware automatically refreshes the cache entry when a user
authenticates. This ensures the cache stays fresh without polling.
Configuration
Section titled “Configuration”{ "Identity:Federated:UserCache": { "StalenessThreshold": "1.00:00:00", "EnableLoginTimeSync": true, "IncrementalSyncBatchSize": 50 }}| Property | Default | Description |
|---|---|---|
StalenessThreshold | 24:00:00 | Age after which a cache entry is considered stale |
EnableLoginTimeSync | true | Refresh cache on login |
IncrementalSyncBatchSize | 50 | Batch size for RefreshStaleAsync |
Keycloak provider
Section titled “Keycloak provider”Granit.Identity.Federated.Keycloak implements the full IIdentityProvider against the
Keycloak Admin REST API.
Configuration
Section titled “Configuration”{ "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-usersrealm-management:manage-users
Health check
Section titled “Health check”builder.Services.AddHealthChecks() .AddGranitKeycloakHealthCheck();Verifies Keycloak connectivity by requesting a client_credentials token. Tagged
["readiness", "startup"]. Returns Unhealthy on 401/403, Degraded on 5xx.
OpenTelemetry
Section titled “OpenTelemetry”All Keycloak Admin API calls are traced via IdentityKeycloakActivitySource.
Activity names follow the pattern Granit.Identity.Keycloak.{Operation}.
Cognito provider
Section titled “Cognito provider”Granit.Identity.Federated.Cognito implements the full IIdentityProvider against the
AWS Cognito User Pool API via AWSSDK.CognitoIdentityProvider.
Configuration
Section titled “Configuration”{ "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).
Cognito-specific behavior
Section titled “Cognito-specific behavior”- Groups as roles — Cognito has no native “roles” concept. Groups serve as both
roles and groups.
GetRolesAsyncandGetGroupsAsyncreturn the same data. - No individual session termination — Cognito supports
AdminUserGlobalSignOut(terminate all sessions) but not individual session termination.IIdentityProviderCapabilities.SupportsIndividualSessionTerminationreturnsfalse. - No device tracking — Cognito exposes no per-device data, so the
/devicesendpoint returns an empty list for Cognito deployments.
OpenTelemetry
Section titled “OpenTelemetry”All Cognito User Pool API calls are traced via IdentityCognitoActivitySource.
Activity names follow the pattern cognito.{operation}.
Google Cloud Identity Platform provider
Section titled “Google Cloud Identity Platform provider”Granit.Identity.Federated.GoogleCloud implements the full IIdentityProvider against the
Firebase Auth API via Firebase Admin SDK v3.
Configuration
Section titled “Configuration”{ "Identity": { "GoogleCloud": { "ProjectId": "my-firebase-project", "RolesClaimKey": "roles", "TimeoutSeconds": 30 } }}For Workload Identity (recommended in GKE), omit CredentialFilePath —
Application Default Credentials are used automatically.
Google Cloud-specific behavior
Section titled “Google Cloud-specific behavior”- 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").GetRolesAsyncreturns an empty list;GetUserRolesAsyncextracts from claims. - Groups not supported — Firebase Auth does not support groups.
AddUserToGroupAsyncandRemoveUserFromGroupAsyncthrowNotSupportedException. - No individual session termination — Firebase supports
RevokeRefreshTokens(terminate all sessions) but not individual session termination.IIdentityProviderCapabilities.SupportsIndividualSessionTerminationreturnsfalse. - Credential verification —
VerifyUserCredentialsAsyncvalidates email/password via the Firebase Auth REST API.
Health check
Section titled “Health check”builder.Services.AddHealthChecks() .AddGranitGoogleCloudIdentityHealthCheck();Verifies that ProjectId is configured. Tagged ["readiness"].
OpenTelemetry
Section titled “OpenTelemetry”All Firebase Auth API calls are traced via IdentityGoogleCloudActivitySource.
Activity names follow the pattern firebase.{operation}.
Google Cloud Authentication
Section titled “Google Cloud Authentication”Granit.Authentication.JwtBearer.GoogleCloud configures JWT Bearer authentication for
Firebase Auth tokens and transforms custom claims into standard ClaimTypes.Role.
Configuration
Section titled “Configuration”{ "Authentication:GoogleCloud": { "ProjectId": "my-firebase-project", "RequireHttpsMetadata": true, "AdminRole": "admin", "RolesClaimKey": "roles" }}| Property | Default | Description |
|---|---|---|
ProjectId | — | GCP project ID (derives OIDC authority) |
RequireHttpsMetadata | true | Require 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".
Claims transformation
Section titled “Claims transformation”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.
Endpoints
Section titled “Endpoints”Granit.Identity.Endpoints exposes user cache management as Minimal API endpoints.
| Method | Route | Permission | Description |
|---|---|---|---|
| GET | /identity/users/search | Identity.UserCache.Read | Search cached users |
| GET | /identity/users/{id} | Identity.UserCache.Read | Get by external ID |
| POST | /identity/users/batch | Identity.UserCache.Read | Batch resolve by IDs |
| POST | /identity/users/sync | Identity.UserCache.Sync | Sync specific user |
| POST | /identity/users/sync-all | Identity.UserCache.Sync | Full sync from provider |
| DELETE | /identity/users/{id} | Identity.UserCache.Delete | GDPR erase |
| PATCH | /identity/users/{id}/pseudonymize | Identity.UserCache.Delete | GDPR pseudonymize |
| GET | /identity/users/stats | Identity.UserCache.Read | Cache statistics |
| GET | /identity/users/capabilities | Identity.UserCache.Read | Provider capabilities |
| POST | /identity/webhook | (anonymous, signature-validated) | IdP webhook receiver |
Webhook
Section titled “Webhook”The webhook endpoint receives events from the identity provider and reacts to each:
EventType | Reaction |
|---|---|
user_created / user_updated | Refresh the user cache entry |
user_deleted | Drop the user cache entry |
login | Publish 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.
GDPR operations
Section titled “GDPR operations”Two operations support data subject rights:
// Right to erasure (Art. 17) — hard deleteawait userLookupService.DeleteByIdAsync("user-123", cancellationToken) .ConfigureAwait(false);
// Right to restriction (Art. 18) — pseudonymizeawait userLookupService.PseudonymizeByIdAsync("user-123", cancellationToken) .ConfigureAwait(false);- Delete removes the
UserCacheEntryentirely (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.
Security notifications
Section titled “Security notifications”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.
| Notification | Channels | Opt-out | Trigger |
|---|---|---|---|
Security.Welcome | Yes | User registration | |
Security.PasswordReset | No | Forgot password flow | |
Security.EmailConfirmation | No | Registration / resend | |
Security.PasswordChanged | No | Password change (compromise alert) | |
Security.AccountLocked | Email, InApp | No | Failed login attempts (includes password reset link for self-service unlock) |
Security.TwoFactorChanged | No | 2FA enabled/disabled | |
Security.EmailChangeAlert | No | Email change alert (old address) | |
Security.EmailChangeConfirmation | No | Email change confirm (new address) | |
Security.ImpersonationAlert | Email, InApp | No | Admin 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.
Account lockout notification
Section titled “Account lockout notification”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 flow
Section titled “Email change flow”Email change triggers two notifications from a single EmailChangeRequestedEto event:
Security.EmailChangeAlert→ sent to the current email (security alert)Security.EmailChangeConfirmation→ sent to the new email (usesRecipientOverride)
The confirmation token is generated by ASP.NET Core Identity and encodes the new email — no additional database column is needed.
Federated identity notifications
Section titled “Federated identity notifications”Granit.Identity.Federated.Notifications ships notifications surfacing
federated-provider lifecycle events (Keycloak / Entra ID / Cognito / Firebase).
Tenants opt in via the notifications admin UI.
| Notification | Trigger | Channels | Severity |
|---|---|---|---|
identity.sync_failed | IdentityUserSyncFailedEto — login-time user-cache sync from the federated provider failed | Email, InApp | Warning |
identity.token_exchange_audit | IdentityTokenExchangedEto — token exchange completed (audit trail of who acted on whose behalf) | Email, InApp | Warning |
identity.user_provisioning_removed | IdentityUserDeletedEto — federated user removed from the provider; cache entry deprovisioned | Email, InApp | Info |
Email templates ship in EN + FR; additional cultures are produced via the translation script (US #1311).
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Module | GranitIdentityModule, GranitIdentityFederatedModule, GranitIdentityFederatedKeycloakModule, GranitIdentityFederatedCognitoModule, GranitIdentityFederatedGoogleCloudModule, GranitAuthenticationJwtBearerGoogleCloudModule, GranitIdentityFederatedEntityFrameworkCoreModule, GranitIdentityEndpointsModule, GranitIdentityLocalAspNetIdentityModule | — |
| Abstractions | IIdentityProvider, IIdentityUserReader, IIdentityUserWriter, IIdentityRoleManager, IIdentityGroupManager, IIdentityPasswordManager, IIdentityCredentialVerifier | Granit.Identity |
| Sessions | IUserSessionManager, IUserSessionProvider, IUserDeviceProvider | Granit.Identity |
| Device trust | IDeviceTrustStore, DeviceTrustVerdict, DeviceTrustLevel, IDeviceTrustCookieService, DeviceTrustOptions, UseGranitDeviceTrust() | Granit.Identity · Granit.Identity.Endpoints |
| Lookup | IUserLookupService, IUserCacheStats, IIdentityProviderCapabilities | Granit.Identity |
| Models | IdentityUser, IdentityUserCreate, IdentityUserUpdate, IdentityRole, IdentityGroup, UserSessionDescriptor, UserSessionView, UserDevice | Granit.Identity |
| Keycloak | KeycloakIdentityProvider, KeycloakAdminOptions | Granit.Identity.Federated.Keycloak |
| Cognito | CognitoIdentityProvider, CognitoAdminOptions | Granit.Identity.Federated.Cognito |
| Google Cloud | GoogleCloudIdentityProvider, GoogleCloudIdentityOptions | Granit.Identity.Federated.GoogleCloud |
| Google Cloud Auth | GoogleCloudClaimsTransformation, GoogleCloudAuthenticationOptions | Granit.Authentication.JwtBearer.GoogleCloud |
| EF Core | UserCacheEntry, IUserCacheDbContext, UserCacheOptions | Granit.Identity.Federated.EntityFrameworkCore |
| Endpoints | IdentityEndpointsOptions, IdentityWebhookOptions | Granit.Identity.Endpoints |
| Notifications | WelcomeNotificationType, PasswordResetNotificationType, EmailConfirmationNotificationType, PasswordChangedNotificationType, AccountLockedNotificationType, TwoFactorChangedNotificationType, EmailChangeAlertNotificationType, EmailChangeConfirmationNotificationType, ImpersonationAlertNotificationType | Granit.Identity.Local.Notifications |
| Notifications options | IdentityNotificationOptions | Granit.Identity.Local.Notifications |
| Recipient resolver | GranitIdentityNotificationsModule, IdentityRecipientResolverOptions, AddGranitIdentityRecipientResolver() | Granit.Identity.Notifications |
| Events | PasswordChangedEto, AccountLockedEto (includes LockoutEndUtc + ResetToken), TwoFactorChangedEto, EmailConfirmationRequestedEto, EmailChangeRequestedEto | Granit.Identity.Local |
| Extensions | AddGranitIdentity(), AddIdentityProvider<T>(), AddGranitIdentityKeycloak(), AddGranitIdentityCognito(), AddGranitIdentityGoogleCloud(), AddGranitGoogleCloudAuthentication(), AddGranitIdentityEntityFrameworkCore<T>(), AddGranitIdentityEndpoints(), MapGranitIdentityUserCache(), MapGranitUserSessions(), MapGranitIdentityProvider() | — |
See also
Section titled “See also”- Security module — Authentication, authorization, RBAC permissions
- Privacy module — GDPR data export/deletion, cookie consent
- Persistence module —
AuditedEntity, interceptors - API Reference (auto-generated from XML docs)