Advanced Features
Multi-tenancy
Section titled “Multi-tenancy”All OpenIddict entities implement IMultiTenant with Guid? TenantId:
| Entity | Table | Tenant-scoped |
|---|---|---|
GranitUser | openiddict_users | Yes |
GranitUserGroup | openiddict_user_groups | Yes |
GranitUserGroupMember | openiddict_user_group_members | Yes |
GranitOpenIddictApplication | openiddict_applications | Yes |
GranitOpenIddictAuthorization | openiddict_authorizations | Yes |
GranitOpenIddictScope | openiddict_scopes | Yes |
GranitOpenIddictToken | openiddict_tokens | Yes |
GranitRole | openiddict_roles | No (global) |
Tenant filters are applied automatically by ApplyGranitConventions() in the
OpenIddictDbContext. Applications and scopes with TenantId = null are global
and visible to all tenants.
GranitOpenIddictEntityFrameworkCoreModule declares [DependsOn(typeof(GranitMultiTenancyModule))]
for GDPR-strict tenant isolation in OpenIddict stores.
Entity caching caveat
Section titled “Entity caching caveat”OpenIddict’s built-in entity cache uses ClientId as the sole cache key. In
multi-tenant setups, two tenants with the same ClientId would share cached data —
a cross-tenant pollution risk.
Entity caching is disabled by default (EnableEntityCaching = false). Enable
only for single-tenant deployments where cache performance is critical:
{ "OpenIddict": { "EnableEntityCaching": true }}Per-tenant settings
Section titled “Per-tenant settings”Token lifetimes and security policies are configurable per-tenant at runtime via
Granit.Settings. No redeployment needed.
| Setting | Default | Description |
|---|---|---|
OpenIddict.AccessTokenLifetime | 01:00:00 | Access token lifetime |
OpenIddict.RefreshTokenLifetime | 14.00:00:00 | Refresh token lifetime |
OpenIddict.AuthCodeLifetime | 00:05:00 | Authorization code lifetime |
OpenIddict.MaxLoginAttempts | 5 | Lockout threshold |
OpenIddict.IdleSessionTimeout | 0 | Idle timeout in minutes (0 = off) |
Per-tenant feature flags
Section titled “Per-tenant feature flags”Feature flags are configurable per-tenant via Granit.Features. Resolution order:
Tenant > Plan > Default.
| Feature | Default | Description |
|---|---|---|
OpenIddict.TwoFactor | true | TOTP-based two-factor authentication |
OpenIddict.DeviceFlow | false | OAuth 2.0 Device Authorization Grant (RFC 8628) |
OpenIddict.ExternalLogins | true | External login providers (Google, Microsoft, GitHub) |
OpenIddict.PkceRequired | true | PKCE requirement for authorization code flows |
OpenIddict.Passkeys | false | WebAuthn/FIDO2 passkey authentication |
Impersonation
Section titled “Impersonation”Administrators can act as another user without knowing their password. This is essential for support scenarios, debugging user-specific issues, and testing tenant-scoped configurations.
sequenceDiagram
participant Admin
participant API as /api/admin/users/{id}/impersonate
participant Token as Token Service
participant Audit as Audit Log
participant Events as Event Bus
Admin->>API: POST /api/admin/users/{userId}/impersonate
API->>API: Verify no chain-impersonation
API->>Token: Issue short-lived token (max 1h)
Token-->>API: Access + Refresh tokens with impersonator_id claim
API->>Audit: Write audit log entry
API->>Events: Publish UserImpersonatedEto
API-->>Admin: 200 OK { accessToken, refreshToken, expiresIn }
Note over Admin: Admin uses impersonation token for API calls
Admin->>API: POST /api/account/session/back-to-impersonator
API->>Token: Issue fresh admin tokens
Token-->>API: Original admin tokens
API-->>Admin: 200 OK { accessToken, refreshToken, expiresIn }
Security constraints
Section titled “Security constraints”| Constraint | Detail |
|---|---|
| No chaining | An impersonated token cannot impersonate another user (guards on impersonator_id claim) |
| Max duration | 1 hour, hardcoded, non-configurable |
| Permission | Requires OpenIddict.Users.Impersonate permission |
| Audit trail | Mandatory IAuditingWriter entry on every impersonation |
| Transparency | UserImpersonatedEto published via IDistributedEventBus — consumed by Granit.Notifications to notify the impersonated user (GDPR / SOC2) |
Impersonation claims
Section titled “Impersonation claims”The impersonation token includes two additional claims:
| Claim | Type | Description |
|---|---|---|
impersonator_id | string | Original administrator’s user ID |
impersonator_name | string | Original administrator’s display name |
Detecting impersonation in code
Section titled “Detecting impersonation in code”using Granit.OpenIddict.Extensions;
// On ClaimsPrincipalif (httpContext.User.IsImpersonated()){ string? adminId = httpContext.User.FindImpersonatorUserId(); string? adminName = httpContext.User.FindImpersonatorName();}
// On HttpContext (convenience)if (httpContext.IsImpersonated()){ // ...}These extension methods are defined in ClaimsPrincipalExtensions in the
Granit.OpenIddict package (no dependency on Endpoints or EF Core).
Idle session timeout
Section titled “Idle session timeout”Automatically revokes refresh tokens when a user session has been inactive longer
than a configurable timeout. Disabled by default (IdleSessionTimeout = 0).
Architecture
Section titled “Architecture”sequenceDiagram
participant Frontend
participant API as /api/account/session/heartbeat
participant Cache as IDistributedCache
participant Job as IdleSessionEnforcementJob
loop Every 5 minutes (while user is active)
Frontend->>API: POST heartbeat
API->>Cache: SET session:{userId}:{jti} = LastActivityAt (TTL: timeout + 5 min)
API-->>Frontend: 204 No Content
end
Note over Frontend: User goes idle, heartbeats stop
loop Every 5 minutes
Job->>Cache: GET session:{userId}:{jti}
Cache-->>Job: null (expired)
Job->>Job: Revoke refresh token
end
How it works
Section titled “How it works”- Frontend calls
POST /api/account/session/heartbeatperiodically (recommended: every 5 minutes) while the user is active - Heartbeat endpoint writes a
UserSessionActivityrecord toIDistributedCachewith keysession:{userId}:{jti}and TTL =IdleSessionTimeout + 5 minbuffer - Background job (
openiddict-idle-session-enforcement, runs every 5 minutes) scans active refresh tokens and checks if the corresponding cache entry still exists - Missing cache entry means the session is idle — the refresh token is revoked
Exclusions
Section titled “Exclusions”Sessions with remember_me = true claim are excluded from idle timeout. The
heartbeat endpoint is a no-op for these sessions.
Configuration
Section titled “Configuration”Set the idle timeout in minutes per-tenant via Granit.Settings:
| Setting key | Default | Range |
|---|---|---|
OpenIddict.IdleSessionTimeout | 0 (disabled) | 0 = off, 1+ = minutes |
{ "OpenIddict": { "IdleSessionTimeout": "30" }}Passkeys (WebAuthn / FIDO2)
Section titled “Passkeys (WebAuthn / FIDO2)”Passkey support is gated behind the OpenIddict.Passkeys feature flag (default:
false). Uses ASP.NET Core Identity’s built-in .NET 10 WebAuthn support:
UserManager.CreatePasskeyAsync() and UserManager.VerifyPasskeyAsync().
Service interface
Section titled “Service interface”The IPasskeyService abstraction provides:
| Method | Description |
|---|---|
GetPasskeysAsync() | List registered passkeys for a user |
BeginRegistrationAsync() | Return PublicKeyCredentialCreationOptions JSON |
CompleteRegistrationAsync() | Validate attestation and store credential |
BeginAssertionAsync() | Return PublicKeyCredentialRequestOptions JSON |
RenameAsync() | Update the friendly name |
DeleteAsync() | Remove a passkey (guards against last-credential lockout) |
Conditional UI
Section titled “Conditional UI”Both registration and assertion endpoints return options with mediation: "conditional"
for browser-native passkey autofill. The frontend should use the WebAuthn API’s
conditional mediation to enable autofill suggestions.
Configuration
Section titled “Configuration”{ "OpenIddict": { "Passkeys": { "ServerDomain": "example.com", "AuthenticatorTimeout": "00:05:00", "ChallengeSize": 32 } }}ServerDomain is the WebAuthn Relying Party ID and is required when passkeys are
enabled. It must match the domain where the application is hosted.
Signing key rotation
Section titled “Signing key rotation”The module supports automatic RSA key rotation for signing and encryption keys,
stored encrypted in the database via IStringEncryptionService (Granit.Encryption).
Key lifecycle
Section titled “Key lifecycle”[Generated] → [Active] → [Retired] → [Revoked] → [Pruned] (90 days) (14 days) (immediate) (30 days)| State | Duration | JWKS visible | Signs new tokens | Validates old tokens |
|---|---|---|---|---|
| Active | 90 days (configurable) | Yes | Yes | Yes |
| Retired | 14 days grace period | Yes | No | Yes |
| Revoked | Until pruned | No | No | No |
A new key is generated 7 days before the active key expires (RotationLeadTime),
ensuring zero-downtime rotation.
Configuration
Section titled “Configuration”{ "OpenIddict": { "KeyRotation": { "Enabled": true, "KeyLifetime": "90.00:00:00", "GracePeriod": "14.00:00:00", "RotationLeadTime": "7.00:00:00", "RsaKeySize": 2048, "SigningAlgorithm": "RS256" } }}Security
Section titled “Security”- Key material is encrypted at rest via
IStringEncryptionService(AES-256 / Vault key) - Private keys never appear in plaintext in the database
- The
openiddict-key-rotationjob runs daily at 3 AM (concurrency-safe via Wolverine Outbox) - Revoked keys are pruned from the database after 30 days
Architecture
Section titled “Architecture”The rotation logic lives in KeyRotationService (Granit.OpenIddict.EntityFrameworkCore).
The background job (OpenIddictKeyRotationJob) only calls IKeyRotationService.RotateAsync().
Background jobs
Section titled “Background jobs”Two recurring background jobs are provided by Granit.OpenIddict.BackgroundJobs:
| Job name | Cron | Description |
|---|---|---|
openiddict-token-cleanup | 0 * * * * (hourly) | Prunes expired tokens and orphaned authorizations via IOpenIddictTokenManager.PruneAsync() and IOpenIddictAuthorizationManager.PruneAsync() |
openiddict-idle-session-enforcement | */5 * * * * (every 5 min) | Revokes refresh tokens for idle sessions (only active when IdleSessionTimeout > 0) |
openiddict-key-rotation | 0 3 * * * (daily at 3 AM) | Generates new signing/encryption keys, retires expiring keys, revokes and prunes old keys |
Both jobs implement IBackgroundJob with [RecurringJob] attribute and are
concurrency-safe via Wolverine Outbox.
Customizing schedules
Section titled “Customizing schedules”Override cron expressions via configuration:
{ "BackgroundJobs": { "Jobs": { "openiddict-token-cleanup": "0 */2 * * *", "openiddict-idle-session-enforcement": "*/10 * * * *" } }}Token cleanup details
Section titled “Token cleanup details”OpenIddictTokenCleanupHandler calls:
await tokenManager.PruneAsync(now, cancellationToken);await authorizationManager.PruneAsync(now, cancellationToken);Without this job, expired tokens accumulate in the database. This is acceptable in development but not in production.
Idle session enforcement details
Section titled “Idle session enforcement details”OpenIddictIdleSessionEnforcementHandler:
- Reads
OpenIddict.IdleSessionTimeoutfromISettingProvider - If
0, exits immediately (feature disabled) - Iterates all active refresh tokens via
IOpenIddictTokenManager.ListAsync() - For each token, checks if
session:{subject}:{tokenId}exists inIDistributedCache - Missing cache entry = idle session = refresh token revoked via
TryRevokeAsync()
Declarative seeding
Section titled “Declarative seeding”OIDC applications and scopes can be seeded at startup via configuration. The
OpenIddictSeedContributor implements IDataSeedContributor and performs
idempotent upserts based on ClientId / Name.
{ "OpenIddict": { "Seeding": { "Applications": [ { "ClientId": "my-spa", "ClientSecret": null, "DisplayName": "My SPA (PKCE)", "Permissions": [ "ept:authorization", "ept:token", "ept:logout", "gt:authorization_code", "gt:refresh_token", "scp:openid", "scp:profile", "scp:email", "scp:offline_access" ], "RedirectUris": ["https://app.example.com/callback"], "PostLogoutRedirectUris": ["https://app.example.com"] } ] } }}{ "OpenIddict": { "Seeding": { "Applications": [ { "ClientId": "my-api", "ClientSecret": "super-secret-should-come-from-vault", "DisplayName": "My API (Client Credentials)", "Permissions": [ "ept:token", "gt:client_credentials", "scp:api" ], "RedirectUris": [], "PostLogoutRedirectUris": [] } ] } }}{ "OpenIddict": { "Seeding": { "Scopes": [ { "Name": "api", "DisplayName": "API Access", "Resources": ["my-api"] } ] } }}Running the application multiple times produces no duplicates. Existing applications are updated (permissions, redirect URIs, display name) but secrets are not overwritten on update.
Integration events
Section titled “Integration events”Four integration events are published via IDistributedEventBus (Wolverine outbox,
at-least-once delivery). The OpenIddict module never sends emails directly — it
publishes events and lets subscribers decide the delivery channel.
UserRegisteredEto
Section titled “UserRegisteredEto”Published when a new user registers (self-service or external login auto-registration).
public sealed record UserRegisteredEto( Guid UserId, string Email, Guid? TenantId) : IIntegrationEvent;Published by: AccountRegistrationEndpoints, AspNetExternalLoginService
Typical subscriber: Send welcome email, create onboarding workflow, sync to CRM.
PasswordResetRequestedEto
Section titled “PasswordResetRequestedEto”Published when a user requests a password reset (POST /api/account/forgot-password).
public sealed record PasswordResetRequestedEto( Guid UserId, string Email, string ResetToken, Guid? TenantId) : IIntegrationEvent;Published by: AspNetPasswordResetService
Typical subscriber: Send password reset email with a link containing the ResetToken.
The subscriber builds the URL (e.g., https://app.example.com/reset-password?userId={UserId}&token={ResetToken})
and sends it via Granit.Notifications.Email.
AccountDeletedEto
Section titled “AccountDeletedEto”Published when a user deletes their account (GDPR Article 17 — right to erasure).
public sealed record AccountDeletedEto( Guid UserId, Guid? TenantId) : IIntegrationEvent;Published by: AspNetAccountDeletionService
Typical subscriber: Clean up user-specific data across all modules (blob storage, audit logs, notification preferences, timeline entries, workflow assignments).
UserImpersonatedEto
Section titled “UserImpersonatedEto”Published when an administrator impersonates a user.
public sealed record UserImpersonatedEto( Guid TargetUserId, Guid ImpersonatorId, Guid? TenantId, DateTimeOffset OccurredAt) : IIntegrationEvent;Published by: AdminUserEndpoints.ImpersonateAsync
Typical subscriber: Send transparency notification to the impersonated user (“An administrator accessed your account on {date}”) — GDPR / SOC2 compliance.
Subscribing to events
Section titled “Subscribing to events”Handlers are Wolverine message handlers, auto-discovered at startup:
// In your module (Wolverine handler — auto-discovered)public static class UserRegisteredHandler{ public static async Task HandleAsync( UserRegisteredEto evt, INotificationPublisher publisher, CancellationToken ct) { await publisher.PublishAsync( new WelcomeNotification(), new WelcomeData(evt.Email), [evt.UserId.ToString()], ct); }}Email template rendering
Section titled “Email template rendering”EmailNotificationChannel automatically renders Scriban templates when available:
- Looks for a template named
{NotificationTypeName}(e.g.,Security.Welcome) via any registeredITemplateResolver - If found, renders with Scriban using the notification data as
{{ model.* }} - If not found, falls back to a generic notification email
To provide templates, add embedded resources to your module:
Templates/ Security.Welcome.html ← English (fallback) Security.Welcome.fr.html ← French Security.PasswordReset.html Security.PasswordReset.fr.htmlRegister in your module:
context.Services.AddEmbeddedTemplates(typeof(MyModule).Assembly);Templates use Scriban syntax with {{ model.* }} for data access:
<h1>Welcome!</h1><p>Your account has been created with the email <strong>{{ model.email }}</strong>.</p>Events are delivered in-process by default (Granit.EventBus). For durable delivery
across pod crashes, use Granit.EventBus.Wolverine with the Outbox pattern.