From 35b51c6b962516a9ab4c75b80ebd3c1bc52de2c6 Mon Sep 17 00:00:00 2001 From: ysmoradi Date: Sun, 7 Dec 2025 14:52:51 +0100 Subject: [PATCH 1/8] bit Boilerplate now retrieves keycloak claims dynamically (#11837) --- ...DemoIcon.razor => EnterpriseSsoIcon.razor} | 0 .../Pages/Identity/Components/SocialRow.razor | 6 +- .../Identity/Components/SocialRow.razor.cs | 2 +- .../IdentityController.SocialSignIn.cs | 20 ++++++- .../Program.Services.cs | 28 ++++++--- .../Identity/AppUserClaimsPrincipalFactory.cs | 60 ++++++++++++++++++- .../Boilerplate.Server.Api/appsettings.json | 7 ++- .../Realms/README.md | 13 ++-- .../Realms/demo-realm.json | 27 +++------ .../Boilerplate.Server.Web/appsettings.json | 4 ++ .../src/Shared/Resources/AppStrings.ar.resx | 6 +- .../src/Shared/Resources/AppStrings.de.resx | 6 +- .../src/Shared/Resources/AppStrings.es.resx | 6 +- .../src/Shared/Resources/AppStrings.fa.resx | 4 +- .../src/Shared/Resources/AppStrings.fr.resx | 6 +- .../src/Shared/Resources/AppStrings.hi.resx | 6 +- .../src/Shared/Resources/AppStrings.nl.resx | 6 +- .../src/Shared/Resources/AppStrings.resx | 4 +- .../src/Shared/Resources/AppStrings.sv.resx | 6 +- .../src/Shared/Resources/AppStrings.zh.resx | 6 +- 20 files changed, 152 insertions(+), 71 deletions(-) rename src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/{IdentityServerDemoIcon.razor => EnterpriseSsoIcon.razor} (100%) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/IdentityServerDemoIcon.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/EnterpriseSsoIcon.razor similarity index 100% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/IdentityServerDemoIcon.razor rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/EnterpriseSsoIcon.razor diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/SocialRow.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/SocialRow.razor index af7cfe23f3..cf213b2137 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/SocialRow.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/SocialRow.razor @@ -11,10 +11,10 @@ } else { - @if (supportedProviders.Contains("IdentityServerDemo")) + @if (supportedProviders.Contains("EnterpriseSso")) { - - + + } @if (supportedProviders.Contains("Google")) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/SocialRow.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/SocialRow.razor.cs index b5aeeb79c0..aef2bdb870 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/SocialRow.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/Components/SocialRow.razor.cs @@ -33,5 +33,5 @@ protected override async Task OnInitAsync() private async Task HandleApple() => await OnClick.InvokeAsync("Apple"); private async Task HandleAzureAD() => await OnClick.InvokeAsync("AzureAD"); private async Task HandleFacebook() => await OnClick.InvokeAsync("Facebook"); - private async Task HandleIdentityServerDemo() => await OnClick.InvokeAsync("IdentityServerDemo"); + private async Task HandleEnterpriseSso() => await OnClick.InvokeAsync("EnterpriseSso"); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.SocialSignIn.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.SocialSignIn.cs index f8cffb98fb..d079fb6bc6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.SocialSignIn.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.SocialSignIn.cs @@ -87,6 +87,24 @@ public async Task SocialSignInCallback(string? returnUrl = null, i await userManager.UpdateAsync(user); } + // Using these tokens (if provided by external provider) for further API calls to the provider on behalf of the user. + // Example: Getting more profile data from the provider, posting on behalf of the user or getting user claims updates, etc. + var accessToken = info.AuthenticationTokens?.FirstOrDefault(t => t.Name == "access_token")?.Value; + var refreshToken = info.AuthenticationTokens?.FirstOrDefault(t => t.Name == "refresh_token")?.Value; + var expiresAt = info.AuthenticationTokens?.FirstOrDefault(t => t.Name == "expires_at")?.Value; + if (string.IsNullOrEmpty(refreshToken) is false) + { + await userManager.SetAuthenticationTokenAsync(user, info.LoginProvider, "refresh_token", refreshToken); + } + if (string.IsNullOrEmpty(accessToken) is false) + { + await userManager.SetAuthenticationTokenAsync(user, info.LoginProvider, "access_token", accessToken); + } + if (string.IsNullOrEmpty(expiresAt) is false) + { + await userManager.SetAuthenticationTokenAsync(user, info.LoginProvider, "expires_at", expiresAt); + } + (_, signInPageUri) = await GenerateAutomaticSignInLink(user, returnUrl, originalAuthenticationMethod: "Social"); // Sign in with a magic link, and 2FA will be prompted if already enabled. } catch (Exception exp) @@ -101,7 +119,7 @@ public async Task SocialSignInCallback(string? returnUrl = null, i var redirectRelativeUrl = $"{PageUrls.WebInteropApp}?actionName=SocialSignInCallback&url={Uri.EscapeDataString(signInPageUri!)}&localHttpPort={localHttpPort}"; - if (localHttpPort is not null) + if (localHttpPort is not null) return Redirect(new Uri(new Uri($"http://localhost:{localHttpPort}"), redirectRelativeUrl).ToString()); // Check out Client.web/wwwroot/web-interop-app.html's comments. return Redirect(new Uri(Request.HttpContext.Request.GetWebAppUrl(), redirectRelativeUrl).ToString()); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs index 3586b7d5dd..571e5202a2 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs @@ -398,6 +398,12 @@ void AddDbContext(DbContextOptionsBuilder options) //#endif }); + services.AddHttpClient("Keycloak", c => + { + c.BaseAddress = new Uri(configuration["KEYCLOAK_HTTP"] ?? throw new InvalidOperationException("KEYCLOAK_HTTP configuration is required")); + c.DefaultRequestVersion = HttpVersion.Version11; + }); + services.AddFido2(options => { @@ -637,16 +643,23 @@ private static void AddIdentity(WebApplicationBuilder builder) // While Google, GitHub, Twitter(X), Apple and AzureAD needs configuration in their corresponding developer portals, // the following OpenID Connect configuration would connect to your own Keycloak, Auth0, Okta, Duende IdentityServer, etc. + // It has been enabled only for dev environment, until you prepare your own production ready Keycloak server. if (builder.Environment.IsDevelopment()) { - authenticationBuilder.AddOpenIdConnect("IdentityServerDemo", options => + authenticationBuilder.AddOpenIdConnect("EnterpriseSso", options => { + configuration.GetRequiredSection("Authentication:EnterpriseSso").Bind(options); + var keycloakBaseUrl = configuration["KEYCLOAK_HTTP"]; // Boilerplate.Server.AppHost (Aspire) would pass this value automatically, // you could also use your own Keycloak URL here. if (string.IsNullOrEmpty(keycloakBaseUrl) is false) { - // Keycloak requires the full authority URL including the realm - options.Authority = $"{keycloakBaseUrl.TrimEnd('/')}/realms/demo"; // Checkout src/Server/Boilerplate.Server.AppHost/Realms/demo-realm.json + // The user would sign-in using Keycloak, just like other providers such as Google. + // IdentityController.SocialSignIn's SocialSignInCallback would store refresh token provided by Keycloak + // Laster, AppUserClaimsPrincipalFactory would use the refresh token to retrieve claims (roles etc) from Keycloak. + // This allows seamless integration with Keycloak, that way you could manage users, roles and claims from Keycloak admin console. + // Checkout src/Server/Boilerplate.Server.AppHost/Realms/README.md for more information. + options.Authority = $"{keycloakBaseUrl.TrimEnd('/')}/realms/demo"; } else { @@ -654,22 +667,19 @@ private static void AddIdentity(WebApplicationBuilder builder) options.Authority = "https://demo.duendesoftware.com"; } - options.ClientId = "interactive.confidential"; - options.ClientSecret = "secret"; options.ResponseType = "code"; options.ResponseMode = "query"; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); - options.Scope.Add("api"); // Custom API access scope for accessing protected resources - options.Scope.Add("offline_access"); options.Scope.Add("email"); + options.Scope.Add("offline_access"); // To get refresh tokens + options.Scope.Add("api"); // Sample API scope - options.MapInboundClaims = false; + options.MapInboundClaims = true; options.GetClaimsFromUserInfoEndpoint = true; options.SaveTokens = true; - options.DisableTelemetry = true; options.Prompt = "login"; // Force login every time diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserClaimsPrincipalFactory.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserClaimsPrincipalFactory.cs index ac355756f0..346e6fad16 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserClaimsPrincipalFactory.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/Identity/AppUserClaimsPrincipalFactory.cs @@ -1,8 +1,10 @@ -using Boilerplate.Server.Api.Models.Identity; +using System.IdentityModel.Tokens.Jwt; +using Boilerplate.Server.Api.Models.Identity; namespace Boilerplate.Server.Api.Services.Identity; -public partial class AppUserClaimsPrincipalFactory(UserClaimsService userClaimsService, UserManager userManager, RoleManager roleManager, IOptions optionsAccessor) +public partial class AppUserClaimsPrincipalFactory(UserClaimsService userClaimsService, UserManager userManager, RoleManager roleManager, + IOptions optionsAccessor, IConfiguration configuration, IServiceProvider serviceProvider) : UserClaimsPrincipalFactory(userManager, roleManager, optionsAccessor) { /// @@ -20,9 +22,63 @@ protected override async Task GenerateClaimsAsync(User user) result.AddClaim(sessionClaim); } + await RetrieveKeycloakClaims(user, result); + return result; } + /// + /// Retrieves additional claims from Keycloak and adds them to the user's claims. + /// + private async Task RetrieveKeycloakClaims(User user, ClaimsIdentity result) + { + var keycloakBaseUrl = configuration["KEYCLOAK_HTTP"]; + if (string.IsNullOrEmpty(keycloakBaseUrl) is false) + { + var keycloakRefreshToken = await UserManager.GetAuthenticationTokenAsync(user, "EnterpriseSso", "refresh_token"); + if (string.IsNullOrEmpty(keycloakRefreshToken) is false) + { + var keycloakTokenExpiryDate = DateTimeOffset.Parse(await UserManager.GetAuthenticationTokenAsync(user, "EnterpriseSso", "expires_at") ?? throw new InvalidOperationException("expires_at token is missing")); + var keycloakAccessToken = await UserManager.GetAuthenticationTokenAsync(user, "EnterpriseSso", "access_token") ?? throw new InvalidOperationException("access_token token is missing"); + if (DateTimeOffset.UtcNow >= keycloakTokenExpiryDate) + { + var httpClient = serviceProvider.GetRequiredService().CreateClient("Keycloak"); + var refreshRequestPayload = new FormUrlEncodedContent(new Dictionary + { + { "grant_type", "refresh_token" }, + { "client_id", configuration["Authentication:EnterpriseSso:ClientId"]! }, + { "client_secret", configuration["Authentication:EnterpriseSso:ClientSecret"]! }, + { "refresh_token", keycloakRefreshToken } + }); + using var response = await httpClient.PostAsync($"realms/demo/protocol/openid-connect/token", refreshRequestPayload); + if (response.IsSuccessStatusCode is false) + { + var errorDetails = await response.Content.ReadFromJsonAsync>(); + if (errorDetails?.Any(i => i.Key == "error" && i.Value?.ToString() is "invalid_grant") is true) + throw new UnauthorizedException(errorDetails["error_description"]!.ToString()!).WithData(errorDetails); + throw new InvalidOperationException("Failed to refresh Keycloak access token").WithData(errorDetails ?? []); + } + var responseBody = await response.Content.ReadFromJsonAsync(); + keycloakAccessToken = responseBody!.GetProperty("access_token").GetString(); + } + var handler = new JwtSecurityTokenHandler(); + var parsedKeycloakAccessToken = handler.ReadJwtToken(keycloakAccessToken); + var keycloakClaims = parsedKeycloakAccessToken.Claims + .Select(claim => new Claim( + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.TryGetValue(claim.Type, out var mapped) + ? mapped + : claim.Type, + claim.Value)) + .ToList(); + + foreach (var claim in keycloakClaims) + { + result.AddClaim(claim); + } + } + } + } + /// /// aspnetcore identity's code to retrieve claims is not performant enough, /// because it doesn't have access to navigation properties and has to query the database for user claims, user roles and role claims separately, diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json index 3f72f9ca7f..d9cd1f7505 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json @@ -186,7 +186,12 @@ "ClientSecret": null, "CallbackPath": "/signin-oidc" }, - "AzureAD_Comment": "Azure ADB2C/Azure Entra" + "AzureAD_Comment": "Azure ADB2C/Azure Entra", + "EnterpriseSso": { + "ClientId": "interactive.confidential", + "ClientSecret": "secret" + }, + "EnterpriseSso_Comment": "Configure your Enterprise Sso such as Keycloak or Duende Identity Server" }, "AllowedHosts": "*", "TrustedOrigins": [], diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Realms/README.md b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Realms/README.md index 6ad2a8378f..8a4fb2767c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Realms/README.md +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Realms/README.md @@ -30,11 +30,11 @@ The realm supports multiple languages for localized user interfaces: ### 👥 Pre-configured Users -| Username | Email | Password | Role | Description | +| Username | Email | Password | |----------|-------|----------|------|-------------| -| `alice` | AliceSmith@email.com | `alice` | Standard User | Basic realm user with default permissions | -| `bob` | BobSmith@email.com | `bob` | Standard User | Basic realm user with default permissions | -| `test` | test@bitplatform.dev | `123456` | Super Admin | Administrative user with elevated privileges | +| `alice` | AliceSmith@email.com | `alice` | +| `bob` | BobSmith@email.com | `bob` | +| `test` | test@bitplatform.dev | `123456` | ### 🔗 Client Configuration @@ -49,7 +49,7 @@ The realm supports multiple languages for localized user interfaces: - **`openid`**: Core OpenID Connect identity - **`profile`**: User profile information (name, username) - **`email`**: Email address and verification status -- **`api`**: Custom API access scope for protected resources +- **`api`**: Sample API scope - **`offline_access`**: Refresh token support ### ⚙️ Token Configuration @@ -79,6 +79,7 @@ When running the Boilerplate application with Aspire: 2. **Realm is imported** from this JSON file 3. **Users are immediately available** for testing 4. **Admin Console**: Access at `http://localhost:8080` (admin/P@ssw0rd) +Note: Keycloak's admin panel opens master realm by default; switch to `demo` realm to manage users. ## Security Notes @@ -89,7 +90,7 @@ For production deployments: - Restrict redirect URIs to specific domains - Use proper SSL certificates - Configure appropriate session timeouts -- Enable additional security features (2FA, account lockout policies) +- Enable additional security features for admin user (e.g., 2FA) - Review and minimize granted permissions - Use strong client secrets and rotate them regularly - Enable SMTP server \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Realms/demo-realm.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Realms/demo-realm.json index d857635c0e..0259ff4781 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Realms/demo-realm.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.AppHost/Realms/demo-realm.json @@ -331,33 +331,20 @@ "display.on.consent.screen": "true", "consent.screen.text": "${rolesScopeConsentText}" }, + "protocolMappers": [ { - "id": "client-roles-mapper", - "name": "client roles", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-client-role-mapper", - "consentRequired": false, - "config": { - "user.attribute": "foo", - "access.token.claim": "true", - "claim.name": "resource_access.${client_id}.roles", - "jsonType.label": "String", - "multivalued": "true" - } - }, - { - "id": "realm-roles-mapper", - "name": "realm roles", + "name": "realm roles mapper", "protocol": "openid-connect", "protocolMapper": "oidc-usermodel-realm-role-mapper", "consentRequired": false, "config": { - "user.attribute": "foo", - "access.token.claim": "true", - "claim.name": "realm_access.roles", + "claim.name": "roles", "jsonType.label": "String", - "multivalued": "true" + "multivalued": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" } } ] diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json index aee46d131d..87afe8b2a7 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/appsettings.json @@ -122,6 +122,10 @@ "ClientId": null, "ClientSecret": null, "CallbackPath": "/signin-oidc" + }, + "EnterpriseSso": { + "ClientId": "interactive.confidential", + "ClientSecret": "secret" } }, "Cloudflare": { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.ar.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.ar.resx index 6bad0b2ab1..b6d9b0b7a6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.ar.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.ar.resx @@ -1,4 +1,4 @@ - +