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 @@ +