diff --git a/.github/screenshots/01-landing-page.png b/.github/screenshots/01-landing-page.png
new file mode 100644
index 00000000..3138ad95
Binary files /dev/null and b/.github/screenshots/01-landing-page.png differ
diff --git a/.github/screenshots/02-login-page.png b/.github/screenshots/02-login-page.png
new file mode 100644
index 00000000..0a23beb9
Binary files /dev/null and b/.github/screenshots/02-login-page.png differ
diff --git a/.github/screenshots/03-dashboard.png b/.github/screenshots/03-dashboard.png
new file mode 100644
index 00000000..8f8b4e81
Binary files /dev/null and b/.github/screenshots/03-dashboard.png differ
diff --git a/.github/screenshots/04-admin-hub.png b/.github/screenshots/04-admin-hub.png
new file mode 100644
index 00000000..c299fd0b
Binary files /dev/null and b/.github/screenshots/04-admin-hub.png differ
diff --git a/.github/screenshots/05-admin-users.png b/.github/screenshots/05-admin-users.png
new file mode 100644
index 00000000..12bdc896
Binary files /dev/null and b/.github/screenshots/05-admin-users.png differ
diff --git a/.github/screenshots/06-user-sessions.png b/.github/screenshots/06-user-sessions.png
new file mode 100644
index 00000000..7c70ff1c
Binary files /dev/null and b/.github/screenshots/06-user-sessions.png differ
diff --git a/.github/screenshots/07-keycloak-landing.png b/.github/screenshots/07-keycloak-landing.png
new file mode 100644
index 00000000..3138ad95
Binary files /dev/null and b/.github/screenshots/07-keycloak-landing.png differ
diff --git a/.github/screenshots/08-keycloak-local-login.png b/.github/screenshots/08-keycloak-local-login.png
new file mode 100644
index 00000000..c568f862
Binary files /dev/null and b/.github/screenshots/08-keycloak-local-login.png differ
diff --git a/.github/screenshots/09-keycloak-login-form.png b/.github/screenshots/09-keycloak-login-form.png
new file mode 100644
index 00000000..5ee00046
Binary files /dev/null and b/.github/screenshots/09-keycloak-login-form.png differ
diff --git a/.github/screenshots/10-keycloak-credentials-filled.png b/.github/screenshots/10-keycloak-credentials-filled.png
new file mode 100644
index 00000000..b2281116
Binary files /dev/null and b/.github/screenshots/10-keycloak-credentials-filled.png differ
diff --git a/.github/screenshots/11-keycloak-authenticated-dashboard.png b/.github/screenshots/11-keycloak-authenticated-dashboard.png
new file mode 100644
index 00000000..868d8ee1
Binary files /dev/null and b/.github/screenshots/11-keycloak-authenticated-dashboard.png differ
diff --git a/.github/screenshots/12-keycloak-user-dropdown.png b/.github/screenshots/12-keycloak-user-dropdown.png
new file mode 100644
index 00000000..d0534832
Binary files /dev/null and b/.github/screenshots/12-keycloak-user-dropdown.png differ
diff --git a/.github/screenshots/13-keycloak-admin-hub.png b/.github/screenshots/13-keycloak-admin-hub.png
new file mode 100644
index 00000000..c299fd0b
Binary files /dev/null and b/.github/screenshots/13-keycloak-admin-hub.png differ
diff --git a/.github/screenshots/14-keycloak-admin-users.png b/.github/screenshots/14-keycloak-admin-users.png
new file mode 100644
index 00000000..e9db3286
Binary files /dev/null and b/.github/screenshots/14-keycloak-admin-users.png differ
diff --git a/.gitignore b/.gitignore
index 4e9d32f5..f3c169dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -447,3 +447,7 @@ template/SimpleModule.Host/storage/
# Temporary refactor baseline — not committed
baseline/
.claude/settings.local.json
+
+# Verification artifacts
+.verify/
+.qa/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2dc36e0b..76f08c71 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -34,6 +34,8 @@
+
+
diff --git a/Dockerfile b/Dockerfile
index 6c63ee4b..1606c2c1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -41,8 +41,11 @@ COPY modules/Dashboard/src/SimpleModule.Dashboard.Contracts/*.csproj modules/Das
COPY modules/Dashboard/src/SimpleModule.Dashboard/*.csproj modules/Dashboard/src/SimpleModule.Dashboard/
COPY modules/Users/src/SimpleModule.Users.Contracts/*.csproj modules/Users/src/SimpleModule.Users.Contracts/
COPY modules/Users/src/SimpleModule.Users/*.csproj modules/Users/src/SimpleModule.Users/
+COPY modules/Identity/src/SimpleModule.Identity.Contracts/*.csproj modules/Identity/src/SimpleModule.Identity.Contracts/
COPY modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/*.csproj modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/
COPY modules/OpenIddict/src/SimpleModule.OpenIddict/*.csproj modules/OpenIddict/src/SimpleModule.OpenIddict/
+COPY modules/Keycloak/src/SimpleModule.Keycloak.Contracts/*.csproj modules/Keycloak/src/SimpleModule.Keycloak.Contracts/
+COPY modules/Keycloak/src/SimpleModule.Keycloak/*.csproj modules/Keycloak/src/SimpleModule.Keycloak/
COPY modules/Permissions/src/SimpleModule.Permissions.Contracts/*.csproj modules/Permissions/src/SimpleModule.Permissions.Contracts/
COPY modules/Permissions/src/SimpleModule.Permissions/*.csproj modules/Permissions/src/SimpleModule.Permissions/
COPY modules/Admin/src/SimpleModule.Admin.Contracts/*.csproj modules/Admin/src/SimpleModule.Admin.Contracts/
diff --git a/Dockerfile.worker b/Dockerfile.worker
index 67c00a87..db65de73 100644
--- a/Dockerfile.worker
+++ b/Dockerfile.worker
@@ -49,8 +49,11 @@ COPY modules/Dashboard/src/SimpleModule.Dashboard.Contracts/*.csproj modules/Das
COPY modules/Dashboard/src/SimpleModule.Dashboard/*.csproj modules/Dashboard/src/SimpleModule.Dashboard/
COPY modules/Users/src/SimpleModule.Users.Contracts/*.csproj modules/Users/src/SimpleModule.Users.Contracts/
COPY modules/Users/src/SimpleModule.Users/*.csproj modules/Users/src/SimpleModule.Users/
+COPY modules/Identity/src/SimpleModule.Identity.Contracts/*.csproj modules/Identity/src/SimpleModule.Identity.Contracts/
COPY modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/*.csproj modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/
COPY modules/OpenIddict/src/SimpleModule.OpenIddict/*.csproj modules/OpenIddict/src/SimpleModule.OpenIddict/
+COPY modules/Keycloak/src/SimpleModule.Keycloak.Contracts/*.csproj modules/Keycloak/src/SimpleModule.Keycloak.Contracts/
+COPY modules/Keycloak/src/SimpleModule.Keycloak/*.csproj modules/Keycloak/src/SimpleModule.Keycloak/
COPY modules/Permissions/src/SimpleModule.Permissions.Contracts/*.csproj modules/Permissions/src/SimpleModule.Permissions.Contracts/
COPY modules/Permissions/src/SimpleModule.Permissions/*.csproj modules/Permissions/src/SimpleModule.Permissions/
COPY modules/Admin/src/SimpleModule.Admin.Contracts/*.csproj modules/Admin/src/SimpleModule.Admin.Contracts/
diff --git a/SimpleModule.AppHost/AppHost.cs b/SimpleModule.AppHost/AppHost.cs
index c8f5cd22..b30d86b6 100644
--- a/SimpleModule.AppHost/AppHost.cs
+++ b/SimpleModule.AppHost/AppHost.cs
@@ -1,4 +1,4 @@
-var builder = DistributedApplication.CreateBuilder(args);
+var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder
.AddPostgres("postgres")
@@ -8,12 +8,51 @@
var db = postgres.AddDatabase("simplemoduledb");
-builder
+// Keycloak identity provider (opt-in via --launch-profile Keycloak)
+var useKeycloak = builder.Configuration["Identity:Provider"] == "Keycloak";
+
+IResourceBuilder? keycloak = null;
+if (useKeycloak)
+{
+ var realmImportPath = Path.Combine(builder.AppHostDirectory, "keycloak");
+
+ keycloak = builder
+ .AddContainer("keycloak", "quay.io/keycloak/keycloak", "26.2")
+ .WithHttpEndpoint(port: 8080, targetPort: 8080, name: "http")
+ .WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", "admin")
+ .WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin")
+ .WithEnvironment("KC_HTTP_ENABLED", "true")
+ .WithEnvironment("KC_HOSTNAME_STRICT", "false")
+ .WithEnvironment("KC_HEALTH_ENABLED", "true")
+ .WithBindMount(realmImportPath, "/opt/keycloak/data/import", isReadOnly: true)
+ .WithArgs("start-dev", "--import-realm")
+ .WithLifetime(ContainerLifetime.Persistent);
+}
+
+var host = builder
.AddProject("simplemodule-host")
.WithExternalHttpEndpoints()
.WithReference(db)
.WaitFor(db);
+if (keycloak is not null)
+{
+ host.WithReference(keycloak.GetEndpoint("http"))
+ .WaitFor(keycloak)
+ .WithEnvironment("Identity__Provider", "Keycloak")
+ .WithEnvironment("Keycloak__Authority", "http://localhost:8080/realms/simplemodule")
+ .WithEnvironment("Keycloak__ClientId", "simplemodule-app")
+ .WithEnvironment("Keycloak__ClientSecret", "simplemodule-dev-secret")
+ .WithEnvironment("Keycloak__Realm", "simplemodule")
+ .WithEnvironment(
+ "Keycloak__AdminApiBaseUrl",
+ "http://localhost:8080/admin/realms/simplemodule"
+ )
+ .WithEnvironment("Keycloak__AdminClientId", "simplemodule-admin")
+ .WithEnvironment("Keycloak__AdminClientSecret", "simplemodule-admin-secret")
+ .WithEnvironment("Keycloak__RequireHttpsMetadata", "false");
+}
+
builder
.AddProject("simplemodule-worker")
.WithReference(db)
diff --git a/SimpleModule.AppHost/Properties/launchSettings.json b/SimpleModule.AppHost/Properties/launchSettings.json
index 83c1d2d8..87bc2379 100644
--- a/SimpleModule.AppHost/Properties/launchSettings.json
+++ b/SimpleModule.AppHost/Properties/launchSettings.json
@@ -26,6 +26,20 @@
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18194",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20023"
}
+ },
+ "keycloak": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17119;http://localhost:15076",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21159",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23210",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22061",
+ "Identity__Provider": "Keycloak"
+ }
}
}
}
diff --git a/SimpleModule.AppHost/keycloak/simplemodule-realm.json b/SimpleModule.AppHost/keycloak/simplemodule-realm.json
new file mode 100644
index 00000000..9c6715bf
--- /dev/null
+++ b/SimpleModule.AppHost/keycloak/simplemodule-realm.json
@@ -0,0 +1,120 @@
+{
+ "realm": "simplemodule",
+ "enabled": true,
+ "registrationAllowed": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "sslRequired": "none",
+ "roles": {
+ "realm": [
+ {
+ "name": "Admin",
+ "description": "Full administrative access"
+ },
+ {
+ "name": "User",
+ "description": "Standard user access"
+ }
+ ]
+ },
+ "clients": [
+ {
+ "clientId": "simplemodule-app",
+ "name": "SimpleModule Application",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "simplemodule-dev-secret",
+ "redirectUris": [
+ "https://localhost:5001/keycloak/callback",
+ "http://localhost:5000/keycloak/callback",
+ "https://localhost:*/keycloak/callback",
+ "http://localhost:*/keycloak/callback"
+ ],
+ "webOrigins": ["*"],
+ "standardFlowEnabled": true,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": true,
+ "authorizationServicesEnabled": false,
+ "protocol": "openid-connect",
+ "attributes": {
+ "pkce.code.challenge.method": "S256",
+ "post.logout.redirect.uris": "+"
+ },
+ "defaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ],
+ "protocolMappers": [
+ {
+ "name": "realm-roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "multivalued": "true",
+ "userinfo.token.claim": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "realm_access.roles",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "clientId": "simplemodule-admin",
+ "name": "SimpleModule Admin Service Account",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "simplemodule-admin-secret",
+ "serviceAccountsEnabled": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "protocol": "openid-connect"
+ }
+ ],
+ "users": [
+ {
+ "username": "admin@simplemodule.dev",
+ "email": "admin@simplemodule.dev",
+ "emailVerified": true,
+ "enabled": true,
+ "firstName": "Admin",
+ "lastName": "User",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "Admin123!",
+ "temporary": false
+ }
+ ],
+ "realmRoles": ["Admin", "User"]
+ },
+ {
+ "username": "user@simplemodule.dev",
+ "email": "user@simplemodule.dev",
+ "emailVerified": true,
+ "enabled": true,
+ "firstName": "Test",
+ "lastName": "User",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "User123!",
+ "temporary": false
+ }
+ ],
+ "realmRoles": ["User"]
+ }
+ ],
+ "defaultDefaultClientScopes": [
+ "web-origins",
+ "acr",
+ "profile",
+ "roles",
+ "email"
+ ]
+}
diff --git a/SimpleModule.slnx b/SimpleModule.slnx
index 5b84407f..b68ecbd4 100644
--- a/SimpleModule.slnx
+++ b/SimpleModule.slnx
@@ -37,11 +37,18 @@
+
+
+
+
+
+
+
diff --git a/docs/site/guide/identity.md b/docs/site/guide/identity.md
index c8640f83..fe8f9084 100644
--- a/docs/site/guide/identity.md
+++ b/docs/site/guide/identity.md
@@ -4,91 +4,200 @@ outline: deep
# Identity & Sessions
-The Users module owns the local identity store; the OpenIddict module owns issued tokens. This page covers the user-facing flows that span them: account lockout recovery, phone verification, active-session management, and global sign-out.
+SimpleModule supports pluggable identity providers. The default is **OpenIddict** (self-hosted OAuth2/OIDC server). An alternative **Keycloak** module delegates authentication to an external Keycloak instance.
-## Account lockout and self-service unlock
-
-When ASP.NET Identity locks an account after repeated failed logins the user is redirected to `/Identity/Account/Lockout`. From there `Send unlock email` posts to `/Identity/Account/SendUnlockEmail`, which:
+## Choosing an Identity Provider
-1. Resolves the user by email (silently no-ops on miss to avoid enumeration).
-2. Generates a single-use token bound to the user and the `AccountUnlock` purpose.
-3. Calls `IAccountUnlockEmailSender.SendUnlockLinkAsync(email, unlockLink)`.
+Set `Identity:Provider` in configuration to switch providers:
-Clicking the link lands on `/Identity/Account/UnlockAccount`, which validates the token, calls `userManager.SetLockoutEndDateAsync(...)` to clear the lockout, and signs the user out so they re-enter credentials.
+| Value | Provider | Use case |
+|-------|----------|----------|
+| *(empty/omitted)* | OpenIddict | Self-contained apps, no external dependencies |
+| `Keycloak` | Keycloak | SSO, social login, MFA, LDAP federation, enterprise IdP |
-`IAccountUnlockEmailSender` defaults to `ConsoleAccountUnlockEmailSender` (logs the link). Replace it with a production implementation that hands off to your transactional mail provider:
+Both modules can coexist in the same Host project — only the configured one activates at startup.
-```csharp
-public sealed class MailgunAccountUnlockEmailSender(IMailgunClient client) : IAccountUnlockEmailSender
+```json
+// appsettings.json — Keycloak mode
{
- public Task SendUnlockLinkAsync(string email, string unlockLink) =>
- client.SendAsync(to: email, subject: "Unlock your account", html: Templates.Unlock(unlockLink));
+ "Identity": {
+ "Provider": "Keycloak"
+ },
+ "Keycloak": {
+ "Authority": "http://localhost:8080/realms/simplemodule",
+ "ClientId": "simplemodule-app",
+ "ClientSecret": "your-client-secret",
+ "Realm": "simplemodule",
+ "AdminApiBaseUrl": "http://localhost:8080/admin/realms/simplemodule",
+ "AdminClientId": "simplemodule-admin",
+ "AdminClientSecret": "your-admin-secret",
+ "RequireHttpsMetadata": true
+ }
}
```
-Register the replacement in `Program.cs` after `AddSimpleModuleInfrastructure()`:
-
-```csharp
-builder.Services.AddScoped();
-```
+## Architecture
-## Phone number confirmation
+### Provider-Agnostic Contracts (Identity.Contracts)
-The account manage page collects an unconfirmed phone number and offers `Send code`. That action posts to `/Identity/Account/Manage/SendPhoneVerificationCode`, which uses `userManager.GenerateChangePhoneNumberTokenAsync(...)` and dispatches via `ISmsSender`:
+All modules depend on `SimpleModule.Identity.Contracts`, never on a specific provider:
```csharp
-public interface ISmsSender
+// Provider-agnostic session management
+public interface ISessionContracts
{
- Task SendVerificationCodeAsync(
- ApplicationUser user,
- string phoneNumber,
- string code,
- CancellationToken cancellationToken = default);
+ Task> GetActiveSessionsForUserAsync(string userId, ...);
+ Task TryRevokeSessionForUserAsync(string tokenId, string userId, ...);
+ Task RevokeAllSessionsForUserAsync(string userId, ...);
+ Task RevokeOtherSessionsForUserAsync(string userId, string? currentTokenId, ...);
+}
+
+// Provider metadata
+public interface IIdentityProvider
+{
+ string Name { get; }
+ bool SupportsLocalUsers { get; }
}
```
-Provide your own implementation (Twilio, Vonage, AWS SNS) and register it the same way as the unlock sender. The default `ConsoleSmsSender` writes the code to logs for local development.
+`SessionDto` carries `TokenId`, `Type`, `ApplicationName`, `CreationDate`, `ExpirationDate`, and `IsCurrent`.
-`/Identity/Account/Manage/ConfirmPhoneNumber` verifies the code with `userManager.ChangePhoneNumberAsync(...)`, which sets both the number and `PhoneNumberConfirmed = true`. `/Identity/Account/Manage/RemovePhoneNumber` clears both fields.
+`TryRevokeSessionForUserAsync` returns `RevokeSessionResult.NotFound` for unknown or cross-user tokens and `BlockedCurrent` when the caller tries to revoke their own session.
-## Active sessions
+### Smart Authentication
-Every refresh token issued by OpenIddict represents a live session. The manage page at `/Identity/Account/Manage` lists them so a user can audit and revoke individual logins without changing their password.
+Both providers use a "SmartAuth" policy scheme that selects the authentication handler per-request:
-Sessions are exposed via `IOpenIddictSessionContracts`. The session-grouped overload collapses access + refresh tokens that share an `AuthorizationId` into a single row, so a user can't accidentally revoke half of their own login:
+| Request | OpenIddict | Keycloak |
+|---------|-----------|----------|
+| `Authorization: Bearer ` | OpenIddict validation | JWT Bearer (Keycloak-issued) |
+| Cookie (browser/Inertia) | ASP.NET Identity cookie | OIDC cookie (Keycloak redirect) |
-```csharp
-public interface IOpenIddictSessionContracts
+### Users Module Dual-Mode
+
+The Users module adapts automatically based on the active provider:
+
+| Aspect | OpenIddict mode | Keycloak mode |
+|--------|----------------|---------------|
+| User store | ASP.NET Identity (local DB) | ASP.NET Identity (local DB) + JIT sync from Keycloak |
+| Login pages | Local Inertia views | Redirect to Keycloak |
+| Password management | Local | Keycloak |
+| `IUserContracts` | `UserService` (via `UserManager`) | `ExternalUserService` (direct EF) |
+| Admin user management | Full CRUD | Read-only (mutations throw `NotSupportedException`) |
+
+## OpenIddict (Default)
+
+Self-hosted OAuth2/OIDC server. No external dependencies.
+
+### Grant Types
+
+- **Authorization Code + PKCE** — standard browser flow
+- **Refresh Token** — token renewal
+- **Password Grant** — development/load testing only (set `OpenIddict:AllowPasswordGrant: true`)
+
+### Certificate Management
+
+Production requires signing and encryption certificates:
+
+```json
{
- Task> GetActiveSessionsForUserAsync(
- string userId,
- string? currentTokenId,
- CancellationToken cancellationToken = default);
-
- Task TryRevokeSessionForUserAsync(
- string tokenId,
- string userId,
- string? currentTokenId,
- CancellationToken cancellationToken = default);
-
- Task RevokeAllSessionsForUserAsync(string userId, CancellationToken cancellationToken = default);
-
- Task RevokeOtherSessionsForUserAsync(
- string userId,
- string? currentTokenId,
- CancellationToken cancellationToken = default);
+ "OpenIddict": {
+ "SigningCertPath": "/certs/signing.pfx",
+ "EncryptionCertPath": "/certs/encryption.pfx",
+ "CertPassword": "your-cert-password"
+ }
}
```
-`UserSessionDto` carries `TokenId`, `Type`, `ApplicationName`, `CreationDate`, `ExpirationDate`, and an `IsCurrent` flag set when the row belongs to the request's own session.
+Development uses ephemeral keys automatically.
+
+### OpenIddict Session Management
+
+Sessions are exposed via `IOpenIddictSessionContracts` (extends `ISessionContracts`). Tokens sharing an `AuthorizationId` collapse into a single session row so users can't revoke half of their own login.
+
+## Keycloak
+
+Delegates authentication to an external [Keycloak](https://www.keycloak.org/) server.
+
+### Keycloak Configuration
+
+| Setting | Description |
+|---------|-------------|
+| `Keycloak:Authority` | Realm URL, e.g. `https://keycloak.example.com/realms/simplemodule` |
+| `Keycloak:ClientId` | Application client ID (confidential, auth code + PKCE) |
+| `Keycloak:ClientSecret` | Application client secret |
+| `Keycloak:Realm` | Realm name |
+| `Keycloak:AdminApiBaseUrl` | Admin REST API URL, e.g. `https://keycloak.example.com/admin/realms/simplemodule` |
+| `Keycloak:AdminClientId` | Service account client ID for admin API |
+| `Keycloak:AdminClientSecret` | Service account client secret |
+| `Keycloak:RequireHttpsMetadata` | `true` in production, `false` for local dev |
+
+### Claims Transformation
-`TryRevokeSessionForUserAsync` returns `RevokeSessionResult.NotFound` (404) for unknown or cross-user tokens — the endpoint deliberately does not distinguish "doesn't exist" from "belongs to someone else" — and `BlockedCurrent` (400) when the caller tries to revoke their own session, which would log them out mid-request.
+Keycloak uses non-standard claim structures. `KeycloakClaimsTransformation` normalizes them before `PermissionClaimsTransformation` runs:
-## Sign out everywhere
+| Keycloak claim | Mapped to |
+|---------------|-----------|
+| `realm_access.roles` (JSON) | Individual `ClaimTypes.Role` claims |
+| `preferred_username` | `ClaimTypes.Name` |
+| `sub` | Used as-is (same as OpenIddict) |
-`/Identity/Account/Manage/SignOutEverywhere` calls `RevokeOtherSessionsForUserAsync` (passing the current token id) and then bumps the user's security stamp via `userManager.UpdateSecurityStampAsync(...)`. The stamp change invalidates every cookie auth ticket issued before the bump, so even browser sessions held outside the OAuth flow are forced through re-authentication.
+### JIT User Provisioning
+
+When a Keycloak user first authenticates, `KeycloakUserSyncService` creates a local shadow `ApplicationUser` record with `Id = Keycloak sub`. On subsequent logins, email and display name are updated if they changed in Keycloak.
+
+This ensures local modules (permissions, audit logs, settings) can reference users by ID without depending on the Keycloak API.
+
+### Session Management
+
+`KeycloakSessionService` implements `ISessionContracts` via the [Keycloak Admin REST API](https://www.keycloak.org/docs-api/latest/rest-api/index.html). Token management for the admin API uses a singleton `KeycloakTokenCache` with thread-safe double-checked locking.
+
+### Sign Out Everywhere
+
+The Keycloak module handles `UserSignedOutEverywhereEvent` (published by the Users module) by calling `RevokeAllSessionsForUserAsync`, which maps to `POST /admin/realms/{realm}/users/{userId}/logout` on the Keycloak Admin API.
+
+## Development with Aspire
+
+The Aspire AppHost includes a Keycloak launch profile:
+
+```bash
+# Default (OpenIddict)
+dotnet run --project SimpleModule.AppHost
+
+# Keycloak mode
+dotnet run --project SimpleModule.AppHost --launch-profile keycloak
+```
+
+The `keycloak` profile starts a Keycloak 26.2 container with a pre-imported realm containing:
+
+| Test User | Password | Roles |
+|-----------|----------|-------|
+| `admin@simplemodule.dev` | `Admin123!` | Admin, User |
+| `user@simplemodule.dev` | `User123!` | User |
+
+Keycloak Admin Console: `http://localhost:8080` (admin/admin)
+
+The realm import JSON is at `SimpleModule.AppHost/keycloak/simplemodule-realm.json`.
+
+## Account lockout and self-service unlock
+
+When ASP.NET Identity locks an account after repeated failed logins the user is redirected to `/Identity/Account/Lockout`. From there `Send unlock email` posts to `/Identity/Account/SendUnlockEmail`, which:
+
+1. Resolves the user by email (silently no-ops on miss to avoid enumeration).
+2. Generates a single-use token bound to the user and the `AccountUnlock` purpose.
+3. Calls `IAccountUnlockEmailSender.SendUnlockLinkAsync(email, unlockLink)`.
+
+Clicking the link validates the token, clears the lockout, and signs the user out for re-authentication.
+
+`IAccountUnlockEmailSender` defaults to `ConsoleAccountUnlockEmailSender` (logs the link). Replace it with a production implementation:
+
+```csharp
+builder.Services.AddScoped();
+```
+
+## Phone number confirmation
-For credential-compromise flows, combine `RevokeAllSessionsForUserAsync` with `UpdateSecurityStampAsync` so even cookie-based sessions issued before the stamp bump are invalidated.
+The account manage page uses `ISmsSender` for phone verification codes. Default `ConsoleSmsSender` logs to console. Provide a Twilio/Vonage/AWS SNS implementation for production.
## Next Steps
diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs
index 10c2f9dd..7d7c3df4 100644
--- a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs
+++ b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs
@@ -4,7 +4,7 @@
using SimpleModule.Admin.Contracts;
using SimpleModule.Core;
using SimpleModule.Core.Menu;
-using SimpleModule.OpenIddict.Contracts;
+using SimpleModule.Identity.Contracts;
namespace SimpleModule.Admin;
@@ -18,8 +18,8 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
public void ConfigureMiddleware(IApplicationBuilder app)
{
- // Admin's session-management endpoints inject IOpenIddictSessionContracts, whose
- // implementation lives in SimpleModule.OpenIddict (not its Contracts assembly).
+ // Admin's session-management endpoints inject ISessionContracts, whose
+ // implementation lives in an identity provider module (OpenIddict, Keycloak, etc.).
// Without that module installed, minimal-API parameter binding falls through to
// body-binding and the host crashes at MapModuleEndpoints with the misleading
// "Body was inferred but the method does not allow inferred body parameters."
@@ -27,12 +27,12 @@ public void ConfigureMiddleware(IApplicationBuilder app)
// Use IServiceProviderIsService so we don't actually instantiate the (scoped)
// service from the root provider.
var probe = app.ApplicationServices.GetRequiredService();
- if (!probe.IsService(typeof(IOpenIddictSessionContracts)))
+ if (!probe.IsService(typeof(ISessionContracts)))
{
throw new InvalidOperationException(
- "SimpleModule.Admin requires SimpleModule.OpenIddict to be installed. "
- + "Add a reference to the SimpleModule.OpenIddict package (or project) "
- + "so IOpenIddictSessionContracts can be resolved by Admin's session endpoints."
+ "SimpleModule.Admin requires an identity provider module (OpenIddict or Keycloak) to be installed. "
+ + "Add a reference to an identity provider module "
+ + "so ISessionContracts can be resolved by Admin's session endpoints."
);
}
}
diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminSessionsEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminSessionsEndpoint.cs
index 3175bb9b..cfb74574 100644
--- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminSessionsEndpoint.cs
+++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminSessionsEndpoint.cs
@@ -4,7 +4,7 @@
using SimpleModule.Admin.Contracts;
using SimpleModule.Core;
using SimpleModule.Core.Authorization;
-using SimpleModule.OpenIddict.Contracts;
+using SimpleModule.Identity.Contracts;
namespace SimpleModule.Admin.Endpoints.Admin;
@@ -22,11 +22,7 @@ public void Map(IEndpointRouteBuilder app)
// DELETE /admin/users/{id}/sessions/{tokenId} — Revoke individual session
group.MapDelete(
"/{tokenId}",
- async Task (
- string id,
- string tokenId,
- IOpenIddictSessionContracts sessionContracts
- ) =>
+ async Task (string id, string tokenId, ISessionContracts sessionContracts) =>
{
await sessionContracts.RevokeSessionAsync(tokenId);
@@ -37,7 +33,7 @@ IOpenIddictSessionContracts sessionContracts
// DELETE /admin/users/{id}/sessions — Revoke all sessions
group.MapDelete(
"/",
- async Task (string id, IOpenIddictSessionContracts sessionContracts) =>
+ async Task (string id, ISessionContracts sessionContracts) =>
{
await sessionContracts.RevokeAllSessionsForUserAsync(id);
diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEditEndpoint.cs
index 8aea0ab1..66d21398 100644
--- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEditEndpoint.cs
+++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEditEndpoint.cs
@@ -6,7 +6,7 @@
using SimpleModule.Core;
using SimpleModule.Core.Authorization;
using SimpleModule.Core.Inertia;
-using SimpleModule.OpenIddict.Contracts;
+using SimpleModule.Identity.Contracts;
using SimpleModule.Permissions.Contracts;
using SimpleModule.Users.Contracts;
@@ -26,7 +26,7 @@ public void Map(IEndpointRouteBuilder app)
IUserAdminContracts userAdmin,
IRoleAdminContracts roleAdmin,
IPermissionContracts permissionContracts,
- IOpenIddictSessionContracts sessionContracts,
+ ISessionContracts sessionContracts,
PermissionRegistry permissionRegistry,
string? tab
) =>
diff --git a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj
index 491f54ca..42efe376 100644
--- a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj
+++ b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj
@@ -12,6 +12,6 @@
-
+
diff --git a/modules/Identity/src/SimpleModule.Identity.Contracts/AuthConstants.cs b/modules/Identity/src/SimpleModule.Identity.Contracts/AuthConstants.cs
new file mode 100644
index 00000000..54c7f09f
--- /dev/null
+++ b/modules/Identity/src/SimpleModule.Identity.Contracts/AuthConstants.cs
@@ -0,0 +1,6 @@
+namespace SimpleModule.Identity.Contracts;
+
+public static class IdentityAuthConstants
+{
+ public const string SmartAuthPolicy = "SmartAuth";
+}
diff --git a/modules/Identity/src/SimpleModule.Identity.Contracts/IIdentityProvider.cs b/modules/Identity/src/SimpleModule.Identity.Contracts/IIdentityProvider.cs
new file mode 100644
index 00000000..79a827d2
--- /dev/null
+++ b/modules/Identity/src/SimpleModule.Identity.Contracts/IIdentityProvider.cs
@@ -0,0 +1,7 @@
+namespace SimpleModule.Identity.Contracts;
+
+public interface IIdentityProvider
+{
+ string Name { get; }
+ bool SupportsLocalUsers { get; }
+}
diff --git a/modules/Identity/src/SimpleModule.Identity.Contracts/ISessionContracts.cs b/modules/Identity/src/SimpleModule.Identity.Contracts/ISessionContracts.cs
new file mode 100644
index 00000000..e5c12428
--- /dev/null
+++ b/modules/Identity/src/SimpleModule.Identity.Contracts/ISessionContracts.cs
@@ -0,0 +1,35 @@
+namespace SimpleModule.Identity.Contracts;
+
+public interface ISessionContracts
+{
+ Task> GetActiveSessionsForUserAsync(
+ string userId,
+ CancellationToken cancellationToken = default
+ );
+
+ Task> GetActiveSessionsForUserAsync(
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ );
+
+ Task TryRevokeSessionForUserAsync(
+ string tokenId,
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ );
+
+ Task RevokeSessionAsync(string tokenId, CancellationToken cancellationToken = default);
+
+ Task RevokeAllSessionsForUserAsync(
+ string userId,
+ CancellationToken cancellationToken = default
+ );
+
+ Task RevokeOtherSessionsForUserAsync(
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ );
+}
diff --git a/modules/Identity/src/SimpleModule.Identity.Contracts/RevokeSessionResult.cs b/modules/Identity/src/SimpleModule.Identity.Contracts/RevokeSessionResult.cs
new file mode 100644
index 00000000..b3285d92
--- /dev/null
+++ b/modules/Identity/src/SimpleModule.Identity.Contracts/RevokeSessionResult.cs
@@ -0,0 +1,8 @@
+namespace SimpleModule.Identity.Contracts;
+
+public enum RevokeSessionResult
+{
+ Revoked,
+ NotFound,
+ BlockedCurrent,
+}
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs b/modules/Identity/src/SimpleModule.Identity.Contracts/SessionDto.cs
similarity index 82%
rename from modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs
rename to modules/Identity/src/SimpleModule.Identity.Contracts/SessionDto.cs
index f2516089..c11dbbdf 100644
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs
+++ b/modules/Identity/src/SimpleModule.Identity.Contracts/SessionDto.cs
@@ -1,9 +1,9 @@
using SimpleModule.Core;
-namespace SimpleModule.OpenIddict.Contracts;
+namespace SimpleModule.Identity.Contracts;
[Dto]
-public class UserSessionDto
+public class SessionDto
{
public string TokenId { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
diff --git a/modules/Identity/src/SimpleModule.Identity.Contracts/SimpleModule.Identity.Contracts.csproj b/modules/Identity/src/SimpleModule.Identity.Contracts/SimpleModule.Identity.Contracts.csproj
new file mode 100644
index 00000000..bbedd98c
--- /dev/null
+++ b/modules/Identity/src/SimpleModule.Identity.Contracts/SimpleModule.Identity.Contracts.csproj
@@ -0,0 +1,10 @@
+
+
+ net10.0
+ Library
+ Provider-agnostic identity contracts for SimpleModule. Defines session management and identity provider abstractions implemented by OpenIddict, Keycloak, or other modules.
+
+
+
+
+
diff --git a/modules/Identity/src/SimpleModule.Identity/types.ts b/modules/Identity/src/SimpleModule.Identity/types.ts
new file mode 100644
index 00000000..d50b7439
--- /dev/null
+++ b/modules/Identity/src/SimpleModule.Identity/types.ts
@@ -0,0 +1,10 @@
+// Auto-generated from [Dto] types — do not edit
+export interface SessionDto {
+ tokenId: string;
+ type: string;
+ applicationName: string;
+ creationDate: string | null;
+ expirationDate: string | null;
+ isCurrent: boolean;
+}
+
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/ConfigKeys.cs b/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/ConfigKeys.cs
new file mode 100644
index 00000000..bf36f9a2
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/ConfigKeys.cs
@@ -0,0 +1,13 @@
+namespace SimpleModule.Keycloak.Contracts;
+
+public static class ConfigKeys
+{
+ public const string KeycloakAuthority = "Keycloak:Authority";
+ public const string KeycloakClientId = "Keycloak:ClientId";
+ public const string KeycloakClientSecret = "Keycloak:ClientSecret";
+ public const string KeycloakRealm = "Keycloak:Realm";
+ public const string KeycloakAdminApiBaseUrl = "Keycloak:AdminApiBaseUrl";
+ public const string KeycloakAdminClientId = "Keycloak:AdminClientId";
+ public const string KeycloakAdminClientSecret = "Keycloak:AdminClientSecret";
+ public const string KeycloakRequireHttpsMetadata = "Keycloak:RequireHttpsMetadata";
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/KeycloakModuleConstants.cs b/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/KeycloakModuleConstants.cs
new file mode 100644
index 00000000..ee0c8177
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/KeycloakModuleConstants.cs
@@ -0,0 +1,29 @@
+namespace SimpleModule.Keycloak.Contracts;
+
+public static class KeycloakModuleConstants
+{
+ public const string ModuleName = "Keycloak";
+
+ ///
+ /// The authentication scheme name for the Keycloak OpenID Connect handler.
+ ///
+ public const string OidcSchemeName = "KeycloakOidc";
+
+ public static class Routes
+ {
+ ///
+ /// Initiates OIDC sign-in via Keycloak.
+ ///
+ public const string Login = "/keycloak/login";
+
+ ///
+ /// OIDC sign-in callback handled by the middleware.
+ ///
+ public const string Callback = "/keycloak/callback";
+
+ ///
+ /// Sign-out: clears local session and redirects to Keycloak end-session endpoint.
+ ///
+ public const string Logout = "/keycloak/logout";
+ }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/SimpleModule.Keycloak.Contracts.csproj b/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/SimpleModule.Keycloak.Contracts.csproj
new file mode 100644
index 00000000..9dfe8882
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak.Contracts/SimpleModule.Keycloak.Contracts.csproj
@@ -0,0 +1,11 @@
+
+
+ net10.0
+ Library
+ Contracts for SimpleModule Keycloak identity provider module.
+
+
+
+
+
+
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Endpoints/KeycloakLoginEndpoint.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Endpoints/KeycloakLoginEndpoint.cs
new file mode 100644
index 00000000..d0745b43
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Endpoints/KeycloakLoginEndpoint.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using SimpleModule.Core;
+using SimpleModule.Keycloak.Contracts;
+
+namespace SimpleModule.Keycloak.Endpoints;
+
+public class KeycloakLoginEndpoint : IEndpoint
+{
+ public void Map(IEndpointRouteBuilder app)
+ {
+ app.MapGet(
+ KeycloakModuleConstants.Routes.Login,
+ (HttpContext context, string? returnUrl) =>
+ {
+ var redirectUri = returnUrl ?? "/";
+ return Results.Challenge(
+ new AuthenticationProperties { RedirectUri = redirectUri },
+ [KeycloakModuleConstants.OidcSchemeName]
+ );
+ }
+ )
+ .AllowAnonymous();
+ }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Endpoints/KeycloakLogoutEndpoint.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Endpoints/KeycloakLogoutEndpoint.cs
new file mode 100644
index 00000000..44aa7a80
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Endpoints/KeycloakLogoutEndpoint.cs
@@ -0,0 +1,28 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using SimpleModule.Core;
+using SimpleModule.Keycloak.Contracts;
+
+namespace SimpleModule.Keycloak.Endpoints;
+
+public class KeycloakLogoutEndpoint : IEndpoint
+{
+ public void Map(IEndpointRouteBuilder app)
+ {
+ app.MapPost(
+ KeycloakModuleConstants.Routes.Logout,
+ async (HttpContext context) =>
+ {
+ await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+ await context.SignOutAsync(KeycloakModuleConstants.OidcSchemeName);
+ }
+ )
+ .DisableAntiforgery();
+
+ // GET for OIDC post-logout redirect
+ app.MapGet("/keycloak/signout-callback", () => Results.Redirect("/")).AllowAnonymous();
+ }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Handlers/UserSignedOutEverywhereHandler.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Handlers/UserSignedOutEverywhereHandler.cs
new file mode 100644
index 00000000..77c09a15
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Handlers/UserSignedOutEverywhereHandler.cs
@@ -0,0 +1,21 @@
+using SimpleModule.Identity.Contracts;
+using SimpleModule.Users.Contracts.Events;
+
+namespace SimpleModule.Keycloak.Handlers;
+
+///
+/// When the Users module fires "Sign out everywhere", revoke all Keycloak sessions
+/// for that user. This mirrors the OpenIddict handler pattern — bearer/refresh-token
+/// holders bypass the cookie SecurityStampValidator, so they need explicit revocation
+/// via the event bus.
+///
+public static class UserSignedOutEverywhereHandler
+{
+ public static async Task Handle(
+ UserSignedOutEverywhereEvent message,
+ ISessionContracts sessionContracts
+ )
+ {
+ await sessionContracts.RevokeAllSessionsForUserAsync(message.UserId.Value);
+ }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Hosting/KeycloakOidcEvents.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Hosting/KeycloakOidcEvents.cs
new file mode 100644
index 00000000..bb0b1b6b
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Hosting/KeycloakOidcEvents.cs
@@ -0,0 +1,44 @@
+using System.Security.Claims;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+
+namespace SimpleModule.Keycloak.Hosting;
+
+internal static class KeycloakOidcEvents
+{
+ private const string RealmAccessClaim = "realm_access";
+
+ public static void OnTokenValidated(TokenValidatedContext context)
+ {
+ if (context.Principal?.Identity is not ClaimsIdentity identity)
+ return;
+
+ var realmAccess = context.Principal.FindFirst(RealmAccessClaim);
+ if (realmAccess is null)
+ return;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(realmAccess.Value);
+ if (
+ doc.RootElement.TryGetProperty("roles", out var rolesElement)
+ && rolesElement.ValueKind == JsonValueKind.Array
+ )
+ {
+ foreach (var role in rolesElement.EnumerateArray())
+ {
+ var roleName = role.GetString();
+ if (!string.IsNullOrEmpty(roleName))
+ {
+ identity.AddClaim(new Claim(ClaimTypes.Role, roleName));
+ }
+ }
+ }
+ }
+ catch (JsonException)
+ {
+ // Malformed realm_access — skip silently; KeycloakClaimsTransformation
+ // will also attempt to parse and log the warning.
+ }
+ }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakIdentityProvider.cs b/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakIdentityProvider.cs
new file mode 100644
index 00000000..dfb551f0
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakIdentityProvider.cs
@@ -0,0 +1,15 @@
+using SimpleModule.Identity.Contracts;
+
+namespace SimpleModule.Keycloak;
+
+///
+/// Registers Keycloak as the active identity provider. Keycloak manages users
+/// externally, so returns false —
+/// registration and password-management pages should be hidden when this
+/// provider is active.
+///
+public sealed class KeycloakIdentityProvider : IIdentityProvider
+{
+ public string Name => "Keycloak";
+ public bool SupportsLocalUsers => false;
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakModule.cs b/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakModule.cs
new file mode 100644
index 00000000..43938a45
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakModule.cs
@@ -0,0 +1,142 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using SimpleModule.Core;
+using SimpleModule.Identity.Contracts;
+using SimpleModule.Keycloak.Contracts;
+using SimpleModule.Keycloak.Hosting;
+using SimpleModule.Keycloak.Services;
+
+namespace SimpleModule.Keycloak;
+
+[Module(KeycloakModuleConstants.ModuleName)]
+public class KeycloakModule : IModule
+{
+ public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ var provider = configuration.GetValue("Identity:Provider");
+ if (!string.Equals(provider, "Keycloak", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ // Bind options
+ var keycloakSection = configuration.GetSection(KeycloakOptions.SectionName);
+ services.Configure(keycloakSection);
+ var keycloakOptions = keycloakSection.Get() ?? new KeycloakOptions();
+
+ // Identity provider metadata
+ services.AddSingleton();
+
+ // Session management
+ services.AddScoped();
+ services.AddScoped(sp =>
+ sp.GetRequiredService()
+ );
+
+ // JIT user sync
+ services.AddScoped();
+
+ // Claims transformation: maps Keycloak JWT claims to standard .NET claims.
+ // Runs before PermissionClaimsTransformation (which resolves permissions from roles).
+ services.AddScoped();
+
+ // Singleton token cache for the Keycloak Admin REST API
+ services.AddSingleton();
+
+ // Typed HttpClient for Keycloak Admin REST API
+ services.AddHttpClient();
+
+ // Authentication: JwtBearer for API calls, OIDC for Inertia pages
+ services
+ .AddAuthentication(options =>
+ {
+ options.DefaultScheme = IdentityAuthConstants.SmartAuthPolicy;
+ options.DefaultAuthenticateScheme = IdentityAuthConstants.SmartAuthPolicy;
+ options.DefaultChallengeScheme = IdentityAuthConstants.SmartAuthPolicy;
+ })
+ .AddCookie(
+ CookieAuthenticationDefaults.AuthenticationScheme,
+ options =>
+ {
+ options.LoginPath = KeycloakModuleConstants.Routes.Login;
+ options.LogoutPath = KeycloakModuleConstants.Routes.Logout;
+ }
+ )
+ .AddJwtBearer(
+ JwtBearerDefaults.AuthenticationScheme,
+ options =>
+ {
+ options.Authority = keycloakOptions.Authority;
+ options.Audience = keycloakOptions.ClientId;
+ options.RequireHttpsMetadata = keycloakOptions.RequireHttpsMetadata;
+
+ // Preserve original claim types — prevent the JWT handler from
+ // mapping "sub" -> ClaimTypes.NameIdentifier etc. Keycloak uses
+ // non-standard claim structures (realm_access) which
+ // KeycloakClaimsTransformation handles explicitly.
+ options.MapInboundClaims = false;
+ }
+ )
+ .AddOpenIdConnect(
+ KeycloakModuleConstants.OidcSchemeName,
+ options =>
+ {
+ options.Authority = keycloakOptions.Authority;
+ options.ClientId = keycloakOptions.ClientId;
+ options.ClientSecret = keycloakOptions.ClientSecret;
+ options.ResponseType = OpenIdConnectResponseType.Code;
+ options.SaveTokens = true;
+ options.GetClaimsFromUserInfoEndpoint = true;
+ options.RequireHttpsMetadata = keycloakOptions.RequireHttpsMetadata;
+ options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+ options.MapInboundClaims = false;
+
+ options.Scope.Clear();
+ options.Scope.Add("openid");
+ options.Scope.Add("profile");
+ options.Scope.Add("email");
+ options.Scope.Add("roles");
+
+ options.CallbackPath = KeycloakModuleConstants.Routes.Callback;
+
+ options.TokenValidationParameters.NameClaimType = "preferred_username";
+ options.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
+
+ // Keycloak puts roles in a nested realm_access JSON object.
+ // Extract roles from the token at OIDC level so they're baked
+ // into the cookie identity before any ClaimsTransformation runs.
+ options.Events = new OpenIdConnectEvents
+ {
+ OnTokenValidated = context =>
+ {
+ KeycloakOidcEvents.OnTokenValidated(context);
+ return Task.CompletedTask;
+ },
+ };
+ }
+ )
+ .AddPolicyScheme(
+ IdentityAuthConstants.SmartAuthPolicy,
+ "Smart Authentication",
+ options =>
+ {
+ options.ForwardDefaultSelector = context =>
+ {
+ var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
+ if (
+ authHeader?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
+ == true
+ )
+ return JwtBearerDefaults.AuthenticationScheme;
+
+ // Cookie-based Inertia/browser requests
+ return CookieAuthenticationDefaults.AuthenticationScheme;
+ };
+ }
+ );
+ }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakOptions.cs b/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakOptions.cs
new file mode 100644
index 00000000..ae8184fd
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/KeycloakOptions.cs
@@ -0,0 +1,58 @@
+namespace SimpleModule.Keycloak;
+
+///
+/// Configuration options for the Keycloak identity provider module.
+/// Bound from the "Keycloak" section of appsettings.json.
+///
+public sealed class KeycloakOptions
+{
+ public const string SectionName = "Keycloak";
+
+ ///
+ /// The OpenID Connect authority URL, typically https://keycloak.example.com/realms/{realm}.
+ ///
+ public string Authority { get; set; } = string.Empty;
+
+ ///
+ /// OAuth2 client ID registered in Keycloak for this application.
+ ///
+ public string ClientId { get; set; } = string.Empty;
+
+ ///
+ /// OAuth2 client secret for the application client (confidential client).
+ ///
+ public string ClientSecret { get; set; } = string.Empty;
+
+ ///
+ /// Keycloak realm name (e.g. "simplemodule").
+ ///
+ public string Realm { get; set; } = string.Empty;
+
+ ///
+ /// Base URL for the Keycloak Admin REST API, e.g.
+ /// https://keycloak.example.com/admin/realms/{realm}.
+ /// Kept as for configuration binding compatibility.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Design",
+ "CA1056:URI-like properties should not be strings",
+ Justification = "Configuration binding requires string"
+ )]
+ public string AdminApiBaseUrl { get; set; } = string.Empty;
+
+ ///
+ /// Service-account client ID used for Admin REST API calls.
+ ///
+ public string AdminClientId { get; set; } = string.Empty;
+
+ ///
+ /// Service-account client secret used for Admin REST API calls.
+ ///
+ public string AdminClientSecret { get; set; } = string.Empty;
+
+ ///
+ /// Whether to require HTTPS for the OpenID Connect metadata endpoint.
+ /// Defaults to true; set to false only for local development.
+ ///
+ public bool RequireHttpsMetadata { get; set; } = true;
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakAdminClient.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakAdminClient.cs
new file mode 100644
index 00000000..59bc423d
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakAdminClient.cs
@@ -0,0 +1,162 @@
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace SimpleModule.Keycloak.Services;
+
+///
+/// Typed wrapper for the Keycloak Admin REST API.
+/// Token acquisition is delegated to the singleton
+/// so that the cached token survives across transient HttpClient resolutions.
+///
+public sealed partial class KeycloakAdminClient(
+ HttpClient httpClient,
+ IOptions options,
+ KeycloakTokenCache tokenCache,
+ ILogger logger
+)
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ ///
+ /// Returns the active sessions for a Keycloak user.
+ /// GET /admin/realms/{realm}/users/{userId}/sessions
+ ///
+ public async Task> GetUserSessionsAsync(
+ string userId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var token = await tokenCache.GetTokenAsync(cancellationToken);
+
+ var url = new Uri($"{options.Value.AdminApiBaseUrl.TrimEnd('/')}/users/{userId}/sessions");
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ using var response = await httpClient.SendAsync(request, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ LogGetSessionsFailed(logger, response.StatusCode, userId);
+ return [];
+ }
+
+ var sessions = await response.Content.ReadFromJsonAsync>(
+ JsonOptions,
+ cancellationToken
+ );
+
+ return sessions ?? [];
+ }
+
+ ///
+ /// Deletes a specific session by its Keycloak session ID.
+ /// DELETE /admin/realms/{realm}/sessions/{sessionId}
+ ///
+ public async Task DeleteSessionAsync(
+ string sessionId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var token = await tokenCache.GetTokenAsync(cancellationToken);
+
+ var url = new Uri($"{options.Value.AdminApiBaseUrl.TrimEnd('/')}/sessions/{sessionId}");
+ using var request = new HttpRequestMessage(HttpMethod.Delete, url);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ using var response = await httpClient.SendAsync(request, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ LogDeleteSessionFailed(logger, response.StatusCode, sessionId);
+ }
+
+ return response.IsSuccessStatusCode;
+ }
+
+ ///
+ /// Logs out a user from all sessions.
+ /// POST /admin/realms/{realm}/users/{userId}/logout
+ ///
+ public async Task LogoutUserAsync(
+ string userId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var token = await tokenCache.GetTokenAsync(cancellationToken);
+
+ var url = new Uri($"{options.Value.AdminApiBaseUrl.TrimEnd('/')}/users/{userId}/logout");
+ using var request = new HttpRequestMessage(HttpMethod.Post, url);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+
+ using var response = await httpClient.SendAsync(request, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ LogLogoutFailed(logger, response.StatusCode, userId);
+ }
+
+ return response.IsSuccessStatusCode;
+ }
+
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Keycloak Admin API returned {StatusCode} for GET user sessions (userId={UserId})"
+ )]
+ private static partial void LogGetSessionsFailed(
+ ILogger logger,
+ System.Net.HttpStatusCode statusCode,
+ string userId
+ );
+
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Keycloak Admin API returned {StatusCode} for DELETE session (sessionId={SessionId})"
+ )]
+ private static partial void LogDeleteSessionFailed(
+ ILogger logger,
+ System.Net.HttpStatusCode statusCode,
+ string sessionId
+ );
+
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "Keycloak Admin API returned {StatusCode} for POST logout (userId={UserId})"
+ )]
+ private static partial void LogLogoutFailed(
+ ILogger logger,
+ System.Net.HttpStatusCode statusCode,
+ string userId
+ );
+}
+
+///
+/// Represents a session returned by the Keycloak Admin REST API.
+///
+public sealed class KeycloakSessionDto
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("userId")]
+ public string UserId { get; set; } = string.Empty;
+
+ [JsonPropertyName("ipAddress")]
+ public string? IpAddress { get; set; }
+
+ [JsonPropertyName("start")]
+ public long? Start { get; set; }
+
+ [JsonPropertyName("lastAccess")]
+ public long? LastAccess { get; set; }
+
+ [JsonPropertyName("clients")]
+ public Dictionary? Clients { get; set; }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakClaimsTransformation.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakClaimsTransformation.cs
new file mode 100644
index 00000000..cbaa32f1
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakClaimsTransformation.cs
@@ -0,0 +1,76 @@
+using System.Security.Claims;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Logging;
+
+namespace SimpleModule.Keycloak.Services;
+
+public sealed class KeycloakClaimsTransformation(
+ ILogger logger,
+ KeycloakUserSyncService syncService
+) : IClaimsTransformation
+{
+ private const string RealmAccessClaim = "realm_access";
+ private const string PreferredUsernameClaim = "preferred_username";
+ private const string KeycloakRolesMarker = "keycloak_roles_mapped";
+
+ public async Task TransformAsync(ClaimsPrincipal principal)
+ {
+ if (principal.Identity?.IsAuthenticated != true)
+ return principal;
+
+ if (principal.HasClaim(c => c.Type == KeycloakRolesMarker))
+ return principal;
+
+ var identity = new ClaimsIdentity("Keycloak");
+
+ // Map realm_access.roles -> ClaimTypes.Role (if not already mapped by OIDC events)
+ if (!principal.HasClaim(c => c.Type == ClaimTypes.Role))
+ {
+ var realmAccessClaim = principal.FindFirst(RealmAccessClaim);
+ if (realmAccessClaim is not null)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(realmAccessClaim.Value);
+ if (
+ doc.RootElement.TryGetProperty("roles", out var rolesElement)
+ && rolesElement.ValueKind == JsonValueKind.Array
+ )
+ {
+ foreach (var role in rolesElement.EnumerateArray())
+ {
+ var roleName = role.GetString();
+ if (!string.IsNullOrEmpty(roleName))
+ {
+ identity.AddClaim(new Claim(ClaimTypes.Role, roleName));
+ }
+ }
+ }
+ }
+ catch (JsonException ex)
+ {
+ logger.LogWarning(ex, "Failed to parse Keycloak realm_access claim");
+ }
+ }
+ }
+
+ // Map preferred_username -> ClaimTypes.Name (if not already present)
+ if (!principal.HasClaim(c => c.Type == ClaimTypes.Name))
+ {
+ var preferredUsername = principal.FindFirstValue(PreferredUsernameClaim);
+ if (!string.IsNullOrEmpty(preferredUsername))
+ {
+ identity.AddClaim(new Claim(ClaimTypes.Name, preferredUsername));
+ }
+ }
+
+ identity.AddClaim(new Claim(KeycloakRolesMarker, "true"));
+ principal.AddIdentity(identity);
+
+ // JIT-provision or update the local shadow user record and sync roles
+ await syncService.SyncUserAsync(principal);
+
+ return principal;
+ }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakSessionService.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakSessionService.cs
new file mode 100644
index 00000000..d4d72d72
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakSessionService.cs
@@ -0,0 +1,123 @@
+using SimpleModule.Identity.Contracts;
+
+namespace SimpleModule.Keycloak.Services;
+
+///
+/// Implements by delegating to the Keycloak
+/// Admin REST API via .
+///
+public sealed class KeycloakSessionService(KeycloakAdminClient adminClient) : ISessionContracts
+{
+ public async Task> GetActiveSessionsForUserAsync(
+ string userId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var sessions = await adminClient.GetUserSessionsAsync(userId, cancellationToken);
+ return sessions.Select(s => ToSessionDto(s, currentSessionId: null)).ToList();
+ }
+
+ public async Task> GetActiveSessionsForUserAsync(
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var sessions = await adminClient.GetUserSessionsAsync(userId, cancellationToken);
+ return sessions.Select(s => ToSessionDto(s, currentTokenId)).ToList();
+ }
+
+ public async Task TryRevokeSessionForUserAsync(
+ string tokenId,
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ // In Keycloak, the tokenId maps to the session ID. Guard against
+ // revoking the caller's own session.
+ if (
+ !string.IsNullOrEmpty(currentTokenId)
+ && string.Equals(tokenId, currentTokenId, StringComparison.Ordinal)
+ )
+ {
+ return RevokeSessionResult.BlockedCurrent;
+ }
+
+ // Verify the session belongs to this user before revoking.
+ var sessions = await adminClient.GetUserSessionsAsync(userId, cancellationToken);
+ var target = sessions.FirstOrDefault(s =>
+ string.Equals(s.Id, tokenId, StringComparison.Ordinal)
+ );
+
+ if (target is null)
+ return RevokeSessionResult.NotFound;
+
+ var deleted = await adminClient.DeleteSessionAsync(tokenId, cancellationToken);
+ return deleted ? RevokeSessionResult.Revoked : RevokeSessionResult.NotFound;
+ }
+
+ public async Task RevokeSessionAsync(
+ string tokenId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ await adminClient.DeleteSessionAsync(tokenId, cancellationToken);
+ }
+
+ public async Task RevokeAllSessionsForUserAsync(
+ string userId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ await adminClient.LogoutUserAsync(userId, cancellationToken);
+ }
+
+ public async Task RevokeOtherSessionsForUserAsync(
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var sessions = await adminClient.GetUserSessionsAsync(userId, cancellationToken);
+
+ foreach (var session in sessions)
+ {
+ // Skip the current session.
+ if (
+ !string.IsNullOrEmpty(currentTokenId)
+ && string.Equals(session.Id, currentTokenId, StringComparison.Ordinal)
+ )
+ {
+ continue;
+ }
+
+ await adminClient.DeleteSessionAsync(session.Id, cancellationToken);
+ }
+ }
+
+ private static SessionDto ToSessionDto(KeycloakSessionDto session, string? currentSessionId)
+ {
+ DateTimeOffset? creationDate = session.Start.HasValue
+ ? DateTimeOffset.FromUnixTimeMilliseconds(session.Start.Value)
+ : null;
+
+ // Derive a display name from the first client in the session, if available.
+ string? applicationName = session.Clients?.Values.FirstOrDefault();
+
+ return new SessionDto
+ {
+ TokenId = session.Id,
+ Type = "keycloak_session",
+ ApplicationName = applicationName,
+ CreationDate = creationDate,
+ // Keycloak sessions don't have an explicit per-session expiration in the
+ // admin API response — the session lifetime is governed by realm/client
+ // timeouts. Set to null; the UI should handle null gracefully.
+ ExpirationDate = null,
+ IsCurrent =
+ !string.IsNullOrEmpty(currentSessionId)
+ && string.Equals(session.Id, currentSessionId, StringComparison.Ordinal),
+ };
+ }
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakTokenCache.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakTokenCache.cs
new file mode 100644
index 00000000..5dff183e
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakTokenCache.cs
@@ -0,0 +1,81 @@
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Options;
+
+namespace SimpleModule.Keycloak.Services;
+
+///
+/// Singleton service that manages the Keycloak Admin REST API access token.
+/// Extracted from so that the token survives
+/// across transient HttpClient resolutions.
+///
+public sealed class KeycloakTokenCache(
+ IHttpClientFactory httpClientFactory,
+ IOptions options
+) : IDisposable
+{
+ private readonly SemaphoreSlim _semaphore = new(1, 1);
+ private string? _accessToken;
+ private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue;
+
+ public async Task GetTokenAsync(CancellationToken cancellationToken = default)
+ {
+ if (_accessToken is not null && DateTimeOffset.UtcNow < _tokenExpiry)
+ return _accessToken;
+
+ await _semaphore.WaitAsync(cancellationToken);
+ try
+ {
+ // Double-check after acquiring lock
+ if (_accessToken is not null && DateTimeOffset.UtcNow < _tokenExpiry)
+ return _accessToken;
+
+ var opts = options.Value;
+ var tokenUrl = new Uri($"{opts.Authority.TrimEnd('/')}/protocol/openid-connect/token");
+
+ using var client = httpClientFactory.CreateClient();
+ using var content = new FormUrlEncodedContent(
+ new Dictionary
+ {
+ ["grant_type"] = "client_credentials",
+ ["client_id"] = opts.AdminClientId,
+ ["client_secret"] = opts.AdminClientSecret,
+ }
+ );
+
+ using var response = await client.PostAsync(tokenUrl, content, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var token = await response.Content.ReadFromJsonAsync(
+ cancellationToken: cancellationToken
+ );
+ _accessToken =
+ token?.AccessToken
+ ?? throw new InvalidOperationException(
+ "Keycloak token response missing access_token."
+ );
+
+ // Expire the cached token 30 seconds early to avoid clock-skew issues.
+ var expiresIn = Math.Max(0, (token.ExpiresIn > 30 ? token.ExpiresIn - 30 : 0));
+ _tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn);
+
+ return _accessToken;
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+
+ public void Dispose() => _semaphore.Dispose();
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Performance",
+ "CA1812:Avoid uninstantiated internal classes",
+ Justification = "Instantiated by JSON deserialization"
+ )]
+ private sealed record TokenResponse(
+ [property: JsonPropertyName("access_token")] string AccessToken,
+ [property: JsonPropertyName("expires_in")] int ExpiresIn
+ );
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakUserSyncService.cs b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakUserSyncService.cs
new file mode 100644
index 00000000..8f391ff7
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/Services/KeycloakUserSyncService.cs
@@ -0,0 +1,141 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Logging;
+using SimpleModule.Core.Extensions;
+using SimpleModule.Users.Contracts;
+
+namespace SimpleModule.Keycloak.Services;
+
+public sealed partial class KeycloakUserSyncService(
+ IUserContracts userContracts,
+ RoleManager roleManager,
+ UserManager userManager,
+ ILogger logger
+)
+{
+ public async Task SyncUserAsync(
+ ClaimsPrincipal principal,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var userId = principal.GetUserId();
+ if (string.IsNullOrEmpty(userId))
+ return;
+
+ var email =
+ principal.FindFirstValue(ClaimTypes.Email)
+ ?? principal.FindFirstValue("email")
+ ?? string.Empty;
+
+ var displayName =
+ principal.FindFirstValue(ClaimTypes.Name)
+ ?? principal.FindFirstValue("preferred_username")
+ ?? principal.FindFirstValue("name")
+ ?? email;
+
+ var keycloakRoles = principal
+ .FindAll(ClaimTypes.Role)
+ .Select(c => c.Value)
+ .Where(r => !string.IsNullOrEmpty(r))
+ .ToList();
+
+ var existingUser = await userContracts.GetUserByIdAsync(UserId.From(userId));
+
+ if (existingUser is null)
+ {
+ LogCreatingShadowUser(logger, userId, email);
+
+ await userContracts.CreateUserAsync(
+ new CreateUserRequest
+ {
+ Id = userId,
+ Email = email,
+ DisplayName = displayName,
+ Password = Guid.NewGuid().ToString("N") + "!Aa1",
+ }
+ );
+ }
+ else if (
+ !string.Equals(existingUser.Email, email, StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(existingUser.DisplayName, displayName, StringComparison.Ordinal)
+ )
+ {
+ LogUpdatingShadowUser(logger, userId, email, displayName);
+
+ await userContracts.UpdateUserAsync(
+ UserId.From(userId),
+ new UpdateUserRequest { Email = email, DisplayName = displayName }
+ );
+ }
+
+ await SyncRolesAsync(userId, keycloakRoles);
+ }
+
+ private async Task SyncRolesAsync(string userId, List keycloakRoles)
+ {
+ foreach (var roleName in keycloakRoles)
+ {
+ if (!await roleManager.RoleExistsAsync(roleName))
+ {
+ LogCreatingRole(logger, roleName);
+ await roleManager.CreateAsync(
+ new ApplicationRole
+ {
+ Name = roleName,
+ Description = $"Synced from Keycloak",
+ CreatedAt = DateTime.UtcNow,
+ }
+ );
+ }
+ }
+
+ var user = await userManager.FindByIdAsync(userId);
+ if (user is null)
+ return;
+
+ var currentRoles = await userManager.GetRolesAsync(user);
+ var rolesToAdd = keycloakRoles
+ .Except(currentRoles, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ var rolesToRemove = currentRoles
+ .Except(keycloakRoles, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (rolesToAdd.Count > 0)
+ {
+ LogSyncingRoles(logger, userId, rolesToAdd.Count);
+ await userManager.AddToRolesAsync(user, rolesToAdd);
+ }
+
+ if (rolesToRemove.Count > 0)
+ {
+ await userManager.RemoveFromRolesAsync(user, rolesToRemove);
+ }
+ }
+
+ [LoggerMessage(
+ Level = LogLevel.Information,
+ Message = "Creating shadow user for Keycloak subject {UserId} ({Email})"
+ )]
+ private static partial void LogCreatingShadowUser(ILogger logger, string userId, string email);
+
+ [LoggerMessage(
+ Level = LogLevel.Debug,
+ Message = "Updating shadow user {UserId}: email={Email}, displayName={DisplayName}"
+ )]
+ private static partial void LogUpdatingShadowUser(
+ ILogger logger,
+ string userId,
+ string email,
+ string displayName
+ );
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Creating local role: {RoleName}")]
+ private static partial void LogCreatingRole(ILogger logger, string roleName);
+
+ [LoggerMessage(
+ Level = LogLevel.Information,
+ Message = "Syncing roles for user {UserId}: adding {Count} role(s)"
+ )]
+ private static partial void LogSyncingRoles(ILogger logger, string userId, int count);
+}
diff --git a/modules/Keycloak/src/SimpleModule.Keycloak/SimpleModule.Keycloak.csproj b/modules/Keycloak/src/SimpleModule.Keycloak/SimpleModule.Keycloak.csproj
new file mode 100644
index 00000000..6bce82ea
--- /dev/null
+++ b/modules/Keycloak/src/SimpleModule.Keycloak/SimpleModule.Keycloak.csproj
@@ -0,0 +1,14 @@
+
+
+ net10.0
+ Keycloak identity provider module for SimpleModule. Implements OpenID Connect authentication via Keycloak.
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/AuthConstants.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/AuthConstants.cs
index cfb17935..0abe2a7c 100644
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/AuthConstants.cs
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/AuthConstants.cs
@@ -1,9 +1,11 @@
+using SimpleModule.Identity.Contracts;
+
namespace SimpleModule.OpenIddict.Contracts;
public static class AuthConstants
{
public const string OAuth2Scheme = "oauth2";
- public const string SmartAuthPolicy = "SmartAuth";
+ public const string SmartAuthPolicy = IdentityAuthConstants.SmartAuthPolicy;
public const string OpenIdScope = "openid";
public const string ProfileScope = "profile";
public const string EmailScope = "email";
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs
index 383c489f..27fe5fb3 100644
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs
@@ -1,74 +1,9 @@
-namespace SimpleModule.OpenIddict.Contracts;
-
-public enum RevokeSessionResult
-{
- /// The session existed, was owned by the caller, and has been revoked.
- Revoked,
-
- /// The token id was unknown or belonged to a different user. The endpoint
- /// surfaces this as 404 so the response shape doesn't leak whether a token id
- /// exists for someone else.
- NotFound,
-
- /// The token is part of the caller's own session (shares an authorization
- /// with the request's token). Refused to prevent self-lockout.
- BlockedCurrent,
-}
-
-public interface IOpenIddictSessionContracts
-{
- ///
- /// Returns one row per valid token. Used by the admin tab where each token
- /// (access / refresh / rotation) is shown individually.
- ///
- Task> GetActiveSessionsForUserAsync(
- string userId,
- CancellationToken cancellationToken = default
- );
+using SimpleModule.Identity.Contracts;
- ///
- /// Returns one row per authorization (i.e. per login). Tokens sharing an
- /// AuthorizationId collapse to a single "session" entry so the user can't
- /// revoke their refresh token while leaving their access token live, or
- /// vice versa. The DTO's TokenId is the anchor token id used for
- /// subsequent revoke calls; IsCurrent is set when the group contains
- /// .
- ///
- Task> GetActiveSessionsForUserAsync(
- string userId,
- string? currentTokenId,
- CancellationToken cancellationToken = default
- );
-
- ///
- /// Revokes the authorization containing , but only
- /// if it belongs to and does not share an
- /// authorization with . Returns a result the
- /// endpoint maps to 200 / 400 / 404. Single-load ownership check defends
- /// against cross-user token-id guessing without a separate query.
- ///
- Task TryRevokeSessionForUserAsync(
- string tokenId,
- string userId,
- string? currentTokenId,
- CancellationToken cancellationToken = default
- );
-
- Task RevokeSessionAsync(string tokenId, CancellationToken cancellationToken = default);
-
- Task RevokeAllSessionsForUserAsync(
- string userId,
- CancellationToken cancellationToken = default
- );
+namespace SimpleModule.OpenIddict.Contracts;
- ///
- /// Revokes every valid token for the user except those sharing an authorization
- /// with . When
- /// is null, revokes everything (equivalent to ).
- ///
- Task RevokeOtherSessionsForUserAsync(
- string userId,
- string? currentTokenId,
- CancellationToken cancellationToken = default
- );
-}
+///
+/// OpenIddict-specific session management contract. Inherits the provider-agnostic
+/// so consumers can depend on either interface.
+///
+public interface IOpenIddictSessionContracts : ISessionContracts;
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/SimpleModule.OpenIddict.Contracts.csproj b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/SimpleModule.OpenIddict.Contracts.csproj
index b33451b0..5b0563a4 100644
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/SimpleModule.OpenIddict.Contracts.csproj
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/SimpleModule.OpenIddict.Contracts.csproj
@@ -5,5 +5,6 @@
+
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Hosting/OpenIddictIdentityProvider.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Hosting/OpenIddictIdentityProvider.cs
new file mode 100644
index 00000000..2b56a12f
--- /dev/null
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Hosting/OpenIddictIdentityProvider.cs
@@ -0,0 +1,14 @@
+using SimpleModule.Identity.Contracts;
+
+namespace SimpleModule.OpenIddict.Hosting;
+
+///
+/// Registers OpenIddict as the active identity provider. Exposes metadata
+/// consumed by provider-agnostic infrastructure (e.g. menu items, feature
+/// gates) without a hard dependency on OpenIddict internals.
+///
+public sealed class OpenIddictIdentityProvider : IIdentityProvider
+{
+ public string Name => "OpenIddict";
+ public bool SupportsLocalUsers => true;
+}
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs
index 4fd078d3..e100a4b9 100644
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs
@@ -6,6 +6,7 @@
using SimpleModule.Core.Authorization;
using SimpleModule.Core.Hosting;
using SimpleModule.Database;
+using SimpleModule.Identity.Contracts;
using SimpleModule.OpenIddict.Contracts;
using SimpleModule.OpenIddict.Hosting;
using SimpleModule.OpenIddict.Services;
@@ -19,6 +20,30 @@ public class OpenIddictModule : IModule
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
+ var provider = configuration.GetValue("Identity:Provider");
+ if (string.Equals(provider, "Keycloak", StringComparison.OrdinalIgnoreCase))
+ {
+ services.AddModuleDbContext(
+ configuration,
+ OpenIddictModuleConstants.ModuleName,
+ opts => opts.UseOpenIddict()
+ );
+ services
+ .AddOpenIddict()
+ .AddCore(options =>
+ {
+ options.UseEntityFrameworkCore().UseDbContext();
+ });
+ services.AddSingleton();
+ services.AddScoped(sp =>
+ (IOpenIddictSessionContracts)
+ new OpenIddictSessionContractsAdapter(
+ sp.GetRequiredService()
+ )
+ );
+ return;
+ }
+
// DbContext with OpenIddict EF Core extension
// Note: OpenIddict manages its own tables internally (no public DbSet properties).
// The unified HostDbContext also calls UseOpenIddict() for EF Core migrations.
@@ -106,7 +131,16 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddHostedService();
// Session management contracts
- services.AddScoped();
+ services.AddScoped();
+ services.AddScoped(sp =>
+ sp.GetRequiredService()
+ );
+ services.AddScoped(sp =>
+ sp.GetRequiredService()
+ );
+
+ // Identity provider metadata
+ services.AddSingleton();
// Host-level contributions
services.AddTransient, OpenIddictSwaggerGenSetup>();
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs
index 06ace0a0..32f7f512 100644
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Pages/OpenIddict/ActiveSessions/RevokeSessionEndpoint.cs
@@ -4,6 +4,7 @@
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Core.Extensions;
+using SimpleModule.Identity.Contracts;
using SimpleModule.OpenIddict.Contracts;
namespace SimpleModule.OpenIddict.Pages.OpenIddict.ActiveSessions;
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionContractsAdapter.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionContractsAdapter.cs
new file mode 100644
index 00000000..f5639044
--- /dev/null
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionContractsAdapter.cs
@@ -0,0 +1,41 @@
+using SimpleModule.Identity.Contracts;
+using SimpleModule.OpenIddict.Contracts;
+
+namespace SimpleModule.OpenIddict.Services;
+
+#pragma warning disable CA1812
+internal sealed class OpenIddictSessionContractsAdapter(ISessionContracts inner)
+ : IOpenIddictSessionContracts
+{
+ public Task> GetActiveSessionsForUserAsync(
+ string userId,
+ CancellationToken cancellationToken = default
+ ) => inner.GetActiveSessionsForUserAsync(userId, cancellationToken);
+
+ public Task> GetActiveSessionsForUserAsync(
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ ) => inner.GetActiveSessionsForUserAsync(userId, currentTokenId, cancellationToken);
+
+ public Task TryRevokeSessionForUserAsync(
+ string tokenId,
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ ) => inner.TryRevokeSessionForUserAsync(tokenId, userId, currentTokenId, cancellationToken);
+
+ public Task RevokeSessionAsync(string tokenId, CancellationToken cancellationToken = default) =>
+ inner.RevokeSessionAsync(tokenId, cancellationToken);
+
+ public Task RevokeAllSessionsForUserAsync(
+ string userId,
+ CancellationToken cancellationToken = default
+ ) => inner.RevokeAllSessionsForUserAsync(userId, cancellationToken);
+
+ public Task RevokeOtherSessionsForUserAsync(
+ string userId,
+ string? currentTokenId,
+ CancellationToken cancellationToken = default
+ ) => inner.RevokeOtherSessionsForUserAsync(userId, currentTokenId, cancellationToken);
+}
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs
index 80728b23..55035468 100644
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs
@@ -1,20 +1,22 @@
using OpenIddict.Abstractions;
+using SimpleModule.Identity.Contracts;
using SimpleModule.OpenIddict.Contracts;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace SimpleModule.OpenIddict.Services;
-public sealed class OpenIddictSessionService(
+#pragma warning disable CA1812 // Instantiated via DI
+internal sealed class OpenIddictSessionService(
IOpenIddictTokenManager tokenManager,
IOpenIddictApplicationManager appManager
) : IOpenIddictSessionContracts
{
- public async Task> GetActiveSessionsForUserAsync(
+ public async Task> GetActiveSessionsForUserAsync(
string userId,
CancellationToken cancellationToken = default
)
{
- var sessions = new List();
+ var sessions = new List();
var appNameCache = new Dictionary();
await foreach (var token in tokenManager.FindBySubjectAsync(userId, cancellationToken))
@@ -27,7 +29,7 @@ public async Task> GetActiveSessionsForUserAsync(
return sessions;
}
- public async Task> GetActiveSessionsForUserAsync(
+ public async Task> GetActiveSessionsForUserAsync(
string userId,
string? currentTokenId,
CancellationToken cancellationToken = default
@@ -61,7 +63,7 @@ public async Task> GetActiveSessionsForUserAsync(
bucket.Add(row.Value);
}
- var sessions = new List(groups.Count);
+ var sessions = new List(groups.Count);
foreach (var bucket in groups.Values)
{
// Prefer a refresh token as the anchor so the row reflects the longer-
@@ -102,7 +104,7 @@ currentAuthorizationId is not null
);
sessions.Add(
- new UserSessionDto
+ new SessionDto
{
TokenId = anchor.TokenId,
Type = anchor.Type,
@@ -254,7 +256,7 @@ currentAuthorizationId is not null
}
}
- private async Task BuildDtoAsync(
+ private async Task BuildDtoAsync(
object token,
Dictionary appNameCache,
CancellationToken cancellationToken
@@ -270,7 +272,7 @@ CancellationToken cancellationToken
cancellationToken
);
- return new UserSessionDto
+ return new SessionDto
{
TokenId = row.Value.TokenId,
Type = row.Value.Type,
diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts b/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts
index eb62413a..294e1680 100644
--- a/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts
+++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts
@@ -1,13 +1,4 @@
// Auto-generated from [Dto] types — do not edit
-export interface UserSessionDto {
- tokenId: string;
- type: string;
- applicationName: string;
- creationDate: string | null;
- expirationDate: string | null;
- isCurrent: boolean;
-}
-
export interface OpenIddictPermissions {
}
diff --git a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs
index 84b3bfbc..a6391e41 100644
--- a/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs
+++ b/modules/OpenIddict/tests/SimpleModule.OpenIddict.Tests/Integration/OpenIddictSessionServiceTests.cs
@@ -1,6 +1,7 @@
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Abstractions;
+using SimpleModule.Identity.Contracts;
using SimpleModule.OpenIddict.Contracts;
using SimpleModule.Tests.Shared.Fixtures;
using static OpenIddict.Abstractions.OpenIddictConstants;
diff --git a/modules/Users/src/SimpleModule.Users.Contracts/CreateUserRequest.cs b/modules/Users/src/SimpleModule.Users.Contracts/CreateUserRequest.cs
index 1038e7c2..9ae9ae3c 100644
--- a/modules/Users/src/SimpleModule.Users.Contracts/CreateUserRequest.cs
+++ b/modules/Users/src/SimpleModule.Users.Contracts/CreateUserRequest.cs
@@ -2,6 +2,7 @@ namespace SimpleModule.Users.Contracts;
public class CreateUserRequest
{
+ public string? Id { get; set; }
public string Email { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
diff --git a/modules/Users/src/SimpleModule.Users/RoleAdminService.cs b/modules/Users/src/SimpleModule.Users/RoleAdminService.cs
index c15ef4ac..586fbd00 100644
--- a/modules/Users/src/SimpleModule.Users/RoleAdminService.cs
+++ b/modules/Users/src/SimpleModule.Users/RoleAdminService.cs
@@ -5,7 +5,8 @@
namespace SimpleModule.Users;
-public sealed class RoleAdminService(
+#pragma warning disable CA1812 // Instantiated via DI
+internal sealed class RoleAdminService(
RoleManager roleManager,
UserManager userManager
) : IRoleAdminContracts
diff --git a/modules/Users/src/SimpleModule.Users/Services/ExternalRoleAdminService.cs b/modules/Users/src/SimpleModule.Users/Services/ExternalRoleAdminService.cs
new file mode 100644
index 00000000..87724a19
--- /dev/null
+++ b/modules/Users/src/SimpleModule.Users/Services/ExternalRoleAdminService.cs
@@ -0,0 +1,43 @@
+using SimpleModule.Users.Contracts;
+
+namespace SimpleModule.Users.Services;
+
+#pragma warning disable CA1812 // Instantiated via DI
+internal sealed class ExternalRoleAdminService : IRoleAdminContracts
+{
+ public Task> GetAllRolesAsync()
+ {
+ return Task.FromResult>([]);
+ }
+
+ public Task GetRoleByIdAsync(string id)
+ {
+ return Task.FromResult(null);
+ }
+
+ public Task CreateRoleAsync(string name, string? description)
+ {
+ throw new NotSupportedException(
+ "Role management is handled by the external identity provider."
+ );
+ }
+
+ public Task UpdateRoleAsync(string id, string name, string? description)
+ {
+ throw new NotSupportedException(
+ "Role management is handled by the external identity provider."
+ );
+ }
+
+ public Task DeleteRoleAsync(string id)
+ {
+ throw new NotSupportedException(
+ "Role management is handled by the external identity provider."
+ );
+ }
+
+ public Task HasUsersInRoleAsync(string id)
+ {
+ return Task.FromResult(false);
+ }
+}
diff --git a/modules/Users/src/SimpleModule.Users/Services/ExternalUserAdminService.cs b/modules/Users/src/SimpleModule.Users/Services/ExternalUserAdminService.cs
new file mode 100644
index 00000000..8275a209
--- /dev/null
+++ b/modules/Users/src/SimpleModule.Users/Services/ExternalUserAdminService.cs
@@ -0,0 +1,109 @@
+using SimpleModule.Core;
+using SimpleModule.Users.Contracts;
+
+namespace SimpleModule.Users.Services;
+
+#pragma warning disable CA1812 // Instantiated via DI
+internal sealed class ExternalUserAdminService : IUserAdminContracts
+{
+ public Task> GetUsersPagedAsync(
+ string? search,
+ int page,
+ int pageSize,
+ string? filterStatus = null,
+ string? filterRole = null
+ )
+ {
+ return Task.FromResult(
+ new PagedResult
+ {
+ Items = [],
+ TotalCount = 0,
+ Page = page,
+ PageSize = pageSize,
+ }
+ );
+ }
+
+ public Task GetAdminUserByIdAsync(UserId id)
+ {
+ return Task.FromResult(null);
+ }
+
+ public Task CreateUserWithPasswordAsync(CreateAdminUserRequest request)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task UpdateUserDetailsAsync(UserId id, UpdateAdminUserRequest request)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task SetUserRolesAsync(UserId id, IEnumerable roles)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task ResetPasswordAsync(UserId id, string newPassword)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task LockAccountAsync(UserId id)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task UnlockAccountAsync(UserId id)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task DeactivateAsync(UserId id)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task ReactivateAsync(UserId id)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task ForceEmailReverificationAsync(UserId id)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task ForcePhoneReverificationAsync(UserId id)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+
+ public Task DisableTwoFactorAsync(UserId id)
+ {
+ throw new NotSupportedException(
+ "User management is handled by the external identity provider."
+ );
+ }
+}
diff --git a/modules/Users/src/SimpleModule.Users/Services/ExternalUserService.cs b/modules/Users/src/SimpleModule.Users/Services/ExternalUserService.cs
new file mode 100644
index 00000000..8463ff05
--- /dev/null
+++ b/modules/Users/src/SimpleModule.Users/Services/ExternalUserService.cs
@@ -0,0 +1,119 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using SimpleModule.Users.Contracts;
+using SimpleModule.Users.Contracts.Events;
+using Wolverine;
+
+namespace SimpleModule.Users.Services;
+
+#pragma warning disable CA1812 // Instantiated via DI
+internal sealed partial class ExternalUserService(
+ UsersDbContext db,
+ IMessageBus bus,
+ ILogger logger
+) : IUserContracts
+{
+ public async Task> GetAllUsersAsync()
+ {
+ return await db.Set().Select(u => MapToDto(u)).ToListAsync();
+ }
+
+ public async Task GetUserByIdAsync(UserId id)
+ {
+ var user = await db.Set().FindAsync(id.Value);
+ return user is null ? null : MapToDto(user);
+ }
+
+ public async Task GetCurrentUserAsync(UserId userId)
+ {
+ return await GetUserByIdAsync(userId);
+ }
+
+ public async Task CreateUserAsync(CreateUserRequest request)
+ {
+ var user = new ApplicationUser
+ {
+ Id = request.Id ?? Guid.NewGuid().ToString(),
+ UserName = request.Email,
+ Email = request.Email,
+ DisplayName = request.DisplayName,
+ EmailConfirmed = true,
+ CreatedAt = DateTime.UtcNow,
+ };
+
+ db.Set().Add(user);
+ await db.SaveChangesAsync();
+
+ LogUserCreated(logger, user.Id, user.Email);
+ await bus.PublishAsync(
+ new UserCreatedEvent(UserId.From(user.Id), user.Email ?? string.Empty, user.DisplayName)
+ );
+
+ return MapToDto(user);
+ }
+
+ public async Task UpdateUserAsync(UserId id, UpdateUserRequest request)
+ {
+ var user =
+ await db.Set().FindAsync(id.Value)
+ ?? throw new Core.Exceptions.NotFoundException("User", id);
+
+ user.Email = request.Email;
+ user.UserName = request.Email;
+ user.DisplayName = request.DisplayName;
+
+ await db.SaveChangesAsync();
+
+ LogUserUpdated(logger, user.Id);
+ await bus.PublishAsync(
+ new UserUpdatedEvent(UserId.From(user.Id), user.Email ?? string.Empty, user.DisplayName)
+ );
+
+ return MapToDto(user);
+ }
+
+ public async Task DeleteUserAsync(UserId id)
+ {
+ var user =
+ await db.Set().FindAsync(id.Value)
+ ?? throw new Core.Exceptions.NotFoundException("User", id);
+
+ db.Set().Remove(user);
+ await db.SaveChangesAsync();
+
+ LogUserDeleted(logger, id);
+ await bus.PublishAsync(new UserDeletedEvent(id));
+ }
+
+ public async Task> GetRoleIdsByNamesAsync(
+ IEnumerable roleNames
+ )
+ {
+ var names = roleNames as ICollection ?? roleNames.ToList();
+ return await db.Set()
+ .Where(r => names.Contains(r.Name!))
+ .ToDictionaryAsync(r => r.Name!, r => r.Id);
+ }
+
+ private static UserDto MapToDto(ApplicationUser user) =>
+ new()
+ {
+ Id = UserId.From(user.Id),
+ Email = user.Email ?? string.Empty,
+ DisplayName = user.DisplayName,
+ EmailConfirmed = user.EmailConfirmed,
+ TwoFactorEnabled = user.TwoFactorEnabled,
+ };
+
+ [LoggerMessage(
+ Level = LogLevel.Information,
+ Message = "User {UserId} created with email {Email}"
+ )]
+ private static partial void LogUserCreated(ILogger logger, string userId, string email);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "User {UserId} updated")]
+ private static partial void LogUserUpdated(ILogger logger, string userId);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "User {UserId} deleted")]
+ private static partial void LogUserDeleted(ILogger logger, UserId userId);
+}
diff --git a/modules/Users/src/SimpleModule.Users/SimpleModule.Users.csproj b/modules/Users/src/SimpleModule.Users/SimpleModule.Users.csproj
index 23fe0ca4..3549f5c5 100644
--- a/modules/Users/src/SimpleModule.Users/SimpleModule.Users.csproj
+++ b/modules/Users/src/SimpleModule.Users/SimpleModule.Users.csproj
@@ -3,6 +3,9 @@
net10.0
Users module for SimpleModule. User authentication and identity management via ASP.NET Core Identity.
+
+
+
diff --git a/modules/Users/src/SimpleModule.Users/UserAdminService.cs b/modules/Users/src/SimpleModule.Users/UserAdminService.cs
index f228d976..e3d3851e 100644
--- a/modules/Users/src/SimpleModule.Users/UserAdminService.cs
+++ b/modules/Users/src/SimpleModule.Users/UserAdminService.cs
@@ -8,7 +8,8 @@
namespace SimpleModule.Users;
-public sealed class UserAdminService(
+#pragma warning disable CA1812 // Instantiated via DI
+internal sealed class UserAdminService(
UserManager userManager,
UsersDbContext db,
IMessageBus bus
diff --git a/modules/Users/src/SimpleModule.Users/UserService.cs b/modules/Users/src/SimpleModule.Users/UserService.cs
index 3eeab6a0..9476cc5d 100644
--- a/modules/Users/src/SimpleModule.Users/UserService.cs
+++ b/modules/Users/src/SimpleModule.Users/UserService.cs
@@ -7,7 +7,9 @@
namespace SimpleModule.Users;
-public partial class UserService(
+#pragma warning disable CA1812 // Avoid uninstantiated internal classes (instantiated via DI)
+
+internal sealed partial class UserService(
UserManager userManager,
RoleManager roleManager,
IMessageBus bus,
@@ -56,6 +58,11 @@ public async Task CreateUserAsync(CreateUserRequest request)
DisplayName = request.DisplayName,
};
+ if (request.Id is not null)
+ {
+ user.Id = request.Id;
+ }
+
var result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs
index 7750b586..178841e5 100644
--- a/modules/Users/src/SimpleModule.Users/UsersModule.cs
+++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs
@@ -21,6 +21,27 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
{
services.AddModuleDbContext(configuration, UsersConstants.ModuleName);
+ if (IsExternalIdentityProvider(configuration))
+ {
+ ConfigureExternalMode(services);
+ }
+ else
+ {
+ ConfigureLocalMode(services, configuration);
+ }
+ }
+
+ private static bool IsExternalIdentityProvider(IConfiguration configuration)
+ {
+ var provider = configuration.GetValue("Identity:Provider");
+ return string.Equals(provider, "Keycloak", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void ConfigureLocalMode(
+ IServiceCollection services,
+ IConfiguration configuration
+ )
+ {
services
.AddIdentity()
.AddEntityFrameworkStores()
@@ -28,7 +49,6 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.Configure(configuration.GetSection("Passkeys"));
- // Opt into Identity Schema Version 3 to enable the AspNetUserPasskeys table
services.Configure(options =>
options.Stores.SchemaVersion = IdentitySchemaVersions.Version3
);
@@ -39,10 +59,6 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
options.LogoutPath = "/Identity/Account/Logout";
options.AccessDeniedPath = "/Identity/Account/AccessDenied";
- // /api/* clients (JS, CLI, integration tests) want a bare 401 — not a
- // 302 to /Identity/Account/Login. The default cookie handler sniffs the
- // Accept header but inconsistently, leading to 401 for some routes and
- // 302 for others. Force 401 for any unauthenticated /api request.
options.Events.OnRedirectToLogin = context =>
{
if (
@@ -75,19 +91,37 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
};
});
- // Bridge UsersModuleOptions into ASP.NET Identity options
services.AddSingleton, ApplyUsersModuleOptions>();
services.AddSingleton<
IPostConfigureOptions,
ApplySecurityStampValidatorOptions
>();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddHostedService();
services.AddSingleton, ConsoleEmailSender>();
services.AddSingleton();
services.AddSingleton();
}
+ private static void ConfigureExternalMode(IServiceCollection services)
+ {
+ services
+ .AddIdentity()
+ .AddEntityFrameworkStores()
+ .AddDefaultTokenProviders();
+
+ services.Configure(options =>
+ options.Stores.SchemaVersion = IdentitySchemaVersions.Version3
+ );
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ }
+
public void ConfigurePermissions(PermissionRegistryBuilder builder)
{
builder.AddPermissions();
diff --git a/modules/Users/src/SimpleModule.Users/types.ts b/modules/Users/src/SimpleModule.Users/types.ts
index 65fcdffb..0daf5b32 100644
--- a/modules/Users/src/SimpleModule.Users/types.ts
+++ b/modules/Users/src/SimpleModule.Users/types.ts
@@ -63,6 +63,7 @@ export interface CreateAdminUserRequest {
}
export interface CreateUserRequest {
+ id: string;
email: string;
displayName: string;
password: string;
diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts
index c825e578..b5babb38 100644
--- a/packages/SimpleModule.Client/src/routes.ts
+++ b/packages/SimpleModule.Client/src/routes.ts
@@ -205,6 +205,22 @@ export const routes = {
inbox: () => '/notifications' as const,
},
},
+ admin: {
+ api: {
+ adminRoles: () => '/admin/roles' as const,
+ adminSessions: (id: string | number, tokenId: string | number) => `/admin/users/${id}/sessions/${tokenId}`,
+ adminUsers: () => '/admin/users' as const,
+ },
+ views: {
+ hub: () => '/admin' as const,
+ rolesCreate: () => '/admin/roles/create' as const,
+ rolesEdit: (id: string | number) => `/admin/roles/${id}/edit`,
+ roles: () => '/admin/roles' as const,
+ usersCreate: () => '/admin/users/create' as const,
+ usersEdit: (id: string | number) => `/admin/users/${id}/edit`,
+ users: () => '/admin/users' as const,
+ },
+ },
openIddict: {
api: {
authorization: () => '/connect/authorize' as const,
@@ -222,21 +238,5 @@ export const routes = {
clients: () => '/openiddict/clients' as const,
},
},
- admin: {
- api: {
- adminRoles: () => '/admin/roles' as const,
- adminSessions: (id: string | number, tokenId: string | number) => `/admin/users/${id}/sessions/${tokenId}`,
- adminUsers: () => '/admin/users' as const,
- },
- views: {
- hub: () => '/admin' as const,
- rolesCreate: () => '/admin/roles/create' as const,
- rolesEdit: (id: string | number) => `/admin/roles/${id}/edit`,
- roles: () => '/admin/roles' as const,
- usersCreate: () => '/admin/users/create' as const,
- usersEdit: (id: string | number) => `/admin/users/${id}/edit`,
- users: () => '/admin/users' as const,
- },
- },
} as const;
diff --git a/template/SimpleModule.Host/SimpleModule.Host.csproj b/template/SimpleModule.Host/SimpleModule.Host.csproj
index c861dc9c..3cd27bc3 100644
--- a/template/SimpleModule.Host/SimpleModule.Host.csproj
+++ b/template/SimpleModule.Host/SimpleModule.Host.csproj
@@ -24,6 +24,7 @@
+