Skip to content

Advanced Features

All OpenIddict entities implement IMultiTenant with Guid? TenantId:

EntityTableTenant-scoped
GranitUseropeniddict_usersYes
GranitUserGroupopeniddict_user_groupsYes
GranitUserGroupMemberopeniddict_user_group_membersYes
GranitOpenIddictApplicationopeniddict_applicationsYes
GranitOpenIddictAuthorizationopeniddict_authorizationsYes
GranitOpenIddictScopeopeniddict_scopesYes
GranitOpenIddictTokenopeniddict_tokensYes
GranitRoleopeniddict_rolesNo (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.

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
}
}

Token lifetimes and security policies are configurable per-tenant at runtime via Granit.Settings. No redeployment needed.

SettingDefaultDescription
OpenIddict.AccessTokenLifetime01:00:00Access token lifetime
OpenIddict.RefreshTokenLifetime14.00:00:00Refresh token lifetime
OpenIddict.AuthCodeLifetime00:05:00Authorization code lifetime
OpenIddict.MaxLoginAttempts5Lockout threshold
OpenIddict.IdleSessionTimeout0Idle timeout in minutes (0 = off)

Feature flags are configurable per-tenant via Granit.Features. Resolution order: Tenant > Plan > Default.

FeatureDefaultDescription
OpenIddict.TwoFactortrueTOTP-based two-factor authentication
OpenIddict.DeviceFlowfalseOAuth 2.0 Device Authorization Grant (RFC 8628)
OpenIddict.ExternalLoginstrueExternal login providers (Google, Microsoft, GitHub)
OpenIddict.PkceRequiredtruePKCE requirement for authorization code flows
OpenIddict.PasskeysfalseWebAuthn/FIDO2 passkey authentication

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 }
ConstraintDetail
No chainingAn impersonated token cannot impersonate another user (guards on impersonator_id claim)
Max duration1 hour, hardcoded, non-configurable
PermissionRequires OpenIddict.Users.Impersonate permission
Audit trailMandatory IAuditingWriter entry on every impersonation
TransparencyUserImpersonatedEto published via IDistributedEventBus — consumed by Granit.Notifications to notify the impersonated user (GDPR / SOC2)

The impersonation token includes two additional claims:

ClaimTypeDescription
impersonator_idstringOriginal administrator’s user ID
impersonator_namestringOriginal administrator’s display name
using Granit.OpenIddict.Extensions;
// On ClaimsPrincipal
if (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).

Automatically revokes refresh tokens when a user session has been inactive longer than a configurable timeout. Disabled by default (IdleSessionTimeout = 0).

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
  1. Frontend calls POST /api/account/session/heartbeat periodically (recommended: every 5 minutes) while the user is active
  2. Heartbeat endpoint writes a UserSessionActivity record to IDistributedCache with key session:{userId}:{jti} and TTL = IdleSessionTimeout + 5 min buffer
  3. Background job (openiddict-idle-session-enforcement, runs every 5 minutes) scans active refresh tokens and checks if the corresponding cache entry still exists
  4. Missing cache entry means the session is idle — the refresh token is revoked

Sessions with remember_me = true claim are excluded from idle timeout. The heartbeat endpoint is a no-op for these sessions.

Set the idle timeout in minutes per-tenant via Granit.Settings:

Setting keyDefaultRange
OpenIddict.IdleSessionTimeout0 (disabled)0 = off, 1+ = minutes
{
"OpenIddict": {
"IdleSessionTimeout": "30"
}
}

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().

The IPasskeyService abstraction provides:

MethodDescription
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)

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.

{
"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.

The module supports automatic RSA key rotation for signing and encryption keys, stored encrypted in the database via IStringEncryptionService (Granit.Encryption).

[Generated] → [Active] → [Retired] → [Revoked] → [Pruned]
(90 days) (14 days) (immediate) (30 days)
StateDurationJWKS visibleSigns new tokensValidates old tokens
Active90 days (configurable)YesYesYes
Retired14 days grace periodYesNoYes
RevokedUntil prunedNoNoNo

A new key is generated 7 days before the active key expires (RotationLeadTime), ensuring zero-downtime rotation.

{
"OpenIddict": {
"KeyRotation": {
"Enabled": true,
"KeyLifetime": "90.00:00:00",
"GracePeriod": "14.00:00:00",
"RotationLeadTime": "7.00:00:00",
"RsaKeySize": 2048,
"SigningAlgorithm": "RS256"
}
}
}
  • Key material is encrypted at rest via IStringEncryptionService (AES-256 / Vault key)
  • Private keys never appear in plaintext in the database
  • The openiddict-key-rotation job runs daily at 3 AM (concurrency-safe via Wolverine Outbox)
  • Revoked keys are pruned from the database after 30 days

The rotation logic lives in KeyRotationService (Granit.OpenIddict.EntityFrameworkCore). The background job (OpenIddictKeyRotationJob) only calls IKeyRotationService.RotateAsync().

Two recurring background jobs are provided by Granit.OpenIddict.BackgroundJobs:

Job nameCronDescription
openiddict-token-cleanup0 * * * * (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-rotation0 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.

Override cron expressions via configuration:

{
"BackgroundJobs": {
"Jobs": {
"openiddict-token-cleanup": "0 */2 * * *",
"openiddict-idle-session-enforcement": "*/10 * * * *"
}
}
}

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.

OpenIddictIdleSessionEnforcementHandler:

  1. Reads OpenIddict.IdleSessionTimeout from ISettingProvider
  2. If 0, exits immediately (feature disabled)
  3. Iterates all active refresh tokens via IOpenIddictTokenManager.ListAsync()
  4. For each token, checks if session:{subject}:{tokenId} exists in IDistributedCache
  5. Missing cache entry = idle session = refresh token revoked via TryRevokeAsync()

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"]
}
]
}
}
}

Running the application multiple times produces no duplicates. Existing applications are updated (permissions, redirect URIs, display name) but secrets are not overwritten on update.

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.

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.

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.

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).

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.

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);
}
}

EmailNotificationChannel automatically renders Scriban templates when available:

  1. Looks for a template named {NotificationTypeName} (e.g., Security.Welcome) via any registered ITemplateResolver
  2. If found, renders with Scriban using the notification data as {{ model.* }}
  3. 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.html

Register 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.