diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 4247e0d6..570b0543 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -33,6 +33,7 @@ jobs:
Hosting.GoFeatureFlag.Tests,
Hosting.Golang.Tests,
Hosting.Java.Tests,
+ Hosting.Keycloak.Extensions.Tests,
Hosting.k6.Tests,
Hosting.LavinMQ.Tests,
Hosting.MailPit.Tests,
diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index ba2cd543..f5a7ccdb 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -58,6 +58,12 @@
+
+
+
+
+
+
@@ -156,8 +162,8 @@
-
+
@@ -205,6 +211,7 @@
+
@@ -249,6 +256,7 @@
+
diff --git a/Directory.Build.props b/Directory.Build.props
index 9d073609..7fcb80a2 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,12 +14,14 @@
9
$(AspireMajorVersion).5.0
$(AspireVersion)
+ preview.1.25474.7
9.0.0
9.0.9
1.12.0
4.7.0
9.9.0
false
+ 4.20.72
$(MSBuildThisFileDirectory)
$(RepoRoot)src\Shared
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 712b83a2..d848a884 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,6 +1,7 @@
true
+
@@ -105,6 +106,9 @@
+
+
+
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/AppHost.cs b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/AppHost.cs
new file mode 100644
index 00000000..18c1c41e
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/AppHost.cs
@@ -0,0 +1,30 @@
+using Aspire.Hosting;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var postgres = builder.AddPostgres("keycloak-postgres-dev");
+var dbDev = postgres.AddDatabase("db-dev");
+
+var keycloakDev = builder.AddKeycloak("keycloak-dev")
+ .WithPostgres(dbDev);
+
+var dbUserName = builder.AddParameter("db-username", "postgres");
+var dbPassword = builder.AddParameter("db-password", "Postgres!123");
+
+var postgresProd = builder.AddPostgres("postgres-prod",
+ dbUserName, dbPassword);
+
+var dbProd = postgresProd.AddDatabase("db-prod");
+
+var keycloakProd = builder.AddKeycloak("keycloak-prod")
+ .WithPostgres(dbProd, dbUserName, dbPassword);
+
+
+builder.AddProject("project-dev")
+ .WithReference(keycloakDev)
+ .WaitFor(keycloakDev);
+
+builder.AddProject("project-prod")
+ .WithReference(keycloakProd)
+ .WaitFor(keycloakProd);
+builder.Build().Run();
\ No newline at end of file
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost.csproj b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost.csproj
new file mode 100644
index 00000000..7ce993cb
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Exe
+ e5e65289-cf91-4bda-b628-28a68fb90841
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/Properties/launchSettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..5528d42f
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17044;http://localhost:15216",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21202",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22139"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15216",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19292",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20099"
+ }
+ }
+ }
+}
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/appsettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/appsettings.json
new file mode 100644
index 00000000..31c092aa
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev.csproj b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev.csproj
new file mode 100644
index 00000000..597ecc08
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Keycloak.Extensions.http b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Keycloak.Extensions.http
new file mode 100644
index 00000000..197bf286
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/CommunityToolkit.Aspire.Keycloak.Extensions.http
@@ -0,0 +1,6 @@
+@CommunityToolkit.Aspire.Hosting.Keycloak.Extensions_HostAddress = http://localhost:5030
+
+GET {{CommunityToolkit.Aspire.Hosting.Keycloak.Extensions_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Program.cs b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Program.cs
new file mode 100644
index 00000000..0a40140b
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Program.cs
@@ -0,0 +1,45 @@
+using CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
+
+builder.Services.AddAuthentication()
+ .AddKeycloakJwtBearer("keycloak-dev", "master", jwt =>
+ {
+ if (builder.Environment.IsDevelopment())
+ {
+ //for development only
+ jwt.RequireHttpsMetadata = false;
+ }
+ });
+
+var app = builder.Build();
+
+
+app.UseHttpsRedirection();
+
+var summaries = new[]
+{
+ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
+};
+
+app.MapGet("/weatherforecast", () =>
+ {
+ var forecast = Enumerable.Range(1, 5).Select(index =>
+ new WeatherForecast
+ (
+ DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
+ Random.Shared.Next(-20, 55),
+ summaries[Random.Shared.Next(summaries.Length)]
+ ))
+ .ToArray();
+ return forecast;
+ })
+ .WithName("GetWeatherForecast");
+
+app.Run();
+
+record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
+{
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+}
\ No newline at end of file
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Properties/launchSettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Properties/launchSettings.json
new file mode 100644
index 00000000..73352f69
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5030",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7041;http://localhost:5030",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/appsettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/appsettings.json
new file mode 100644
index 00000000..10f68b8c
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Dev/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod.csproj b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod.csproj
new file mode 100644
index 00000000..cd1583df
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Keycloak.Extensions.Prod.http b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Keycloak.Extensions.Prod.http
new file mode 100644
index 00000000..0347bb1d
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/CommunityToolkit.Aspire.Keycloak.Extensions.Prod.http
@@ -0,0 +1,6 @@
+@CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod_HostAddress = http://localhost:5286
+
+GET {{CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Program.cs b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Program.cs
new file mode 100644
index 00000000..26ea0e05
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Program.cs
@@ -0,0 +1,45 @@
+using CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
+
+builder.Services.AddAuthentication()
+ .AddKeycloakJwtBearer("keycloak-prod", "master", jwt =>
+ {
+ if (builder.Environment.IsDevelopment())
+ {
+ //for development only
+ jwt.RequireHttpsMetadata = false;
+ }
+ });
+
+var app = builder.Build();
+
+
+app.UseHttpsRedirection();
+
+var summaries = new[]
+{
+ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
+};
+
+app.MapGet("/weatherforecast", () =>
+ {
+ var forecast = Enumerable.Range(1, 5).Select(index =>
+ new WeatherForecast
+ (
+ DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
+ Random.Shared.Next(-20, 55),
+ summaries[Random.Shared.Next(summaries.Length)]
+ ))
+ .ToArray();
+ return forecast;
+ })
+ .WithName("GetWeatherForecast");
+
+app.Run();
+
+record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
+{
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+}
\ No newline at end of file
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Properties/launchSettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Properties/launchSettings.json
new file mode 100644
index 00000000..5ec9049c
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5286",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7134;http://localhost:5286",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/appsettings.json b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/appsettings.json
new file mode 100644
index 00000000..10f68b8c
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Prod/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults.csproj b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults.csproj
new file mode 100644
index 00000000..2a0de1fa
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults.csproj
@@ -0,0 +1,19 @@
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/Extensions.cs b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..867a90d2
--- /dev/null
+++ b/examples/keycloak-postgres/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults/Extensions.cs
@@ -0,0 +1,128 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.ServiceDefaults;
+
+// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ // Exclude health check requests from tracing
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder)
+ where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Adding health checks endpoints to applications in non-development environments has security implications.
+ // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
+ if (app.Environment.IsDevelopment())
+ {
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks(HealthEndpointPath);
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks(AlivenessEndpointPath,
+ new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") });
+ }
+
+ return app;
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.csproj b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.csproj
new file mode 100644
index 00000000..22414024
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.csproj
@@ -0,0 +1,14 @@
+
+
+
+ .NET Aspire hosting extensions for Keycloak (includes PostgreSQL integration).
+ keycloak postgres hosting extensions
+
+
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/KeycloakPostgresExtension.cs b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/KeycloakPostgresExtension.cs
new file mode 100644
index 00000000..e8206f53
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/KeycloakPostgresExtension.cs
@@ -0,0 +1,74 @@
+using Aspire.Hosting.ApplicationModel;
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for integrating Keycloak resources with PostgreSQL.
+///
+public static class KeycloakPostgresExtension
+{
+ private static IResourceBuilder WithPostgresData(
+ this IResourceBuilder builder,
+ IResourceBuilder database,
+ bool xaEnabled = false)
+ {
+ var pgServer = database.Resource.Parent;
+ var ep = pgServer.GetEndpoint("tcp");
+
+ var dbName = database.Resource.Name;
+
+ var jdbcUrl = ReferenceExpression.Create(
+ $"jdbc:postgresql://{ep.Property(EndpointProperty.Host)}:{ep.Property(EndpointProperty.Port)}/{dbName}");
+
+ builder.WithEnvironment("KC_DB", "postgres")
+ .WithEnvironment("KC_DB_URL", jdbcUrl)
+ .WithEnvironment("KC_TRANSACTION_XA_ENABLED", xaEnabled.ToString().ToLower())
+ .WaitFor(database);
+
+ return builder;
+ }
+
+ private static ReferenceExpression ToRef(ParameterResource? value)
+ {
+ return value is null ? ReferenceExpression.Create($"postgres") : ReferenceExpression.Create($"{value}");
+ }
+
+ /// Adds support for Keycloak to connect to a specified Postgres database resource, with optional credentials
+ /// and configuration for XA transactions.
+ ///
+ /// The resource builder for configuring a Keycloak resource.
+ ///
+ ///
+ /// The resource builder for the Postgres database that Keycloak will connect to.
+ ///
+ ///
+ /// (Optional) The resource builder for the parameter defining the database username.
+ /// If not provided, the parent database resource's username parameter will be used.
+ ///
+ ///
+ /// (Optional) The resource builder for the parameter defining the database password.
+ /// If not provided, the parent database resource's password parameter will be used.
+ ///
+ ///
+ /// A boolean flag indicating whether XA transactions are enabled. Defaults to false.
+ ///
+ ///
+ /// An updated resource builder with Postgres integration configured for the Keycloak resource.
+ ///
+ public static IResourceBuilder WithPostgres(
+ this IResourceBuilder builder,
+ IResourceBuilder database,
+ IResourceBuilder? username = null,
+ IResourceBuilder? password = null,
+ bool xaEnabled = false)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(database);
+
+ return WithPostgresData(builder, database, xaEnabled)
+ .WithEnvironment("KC_DB_USERNAME", ToRef(username?.Resource ??
+ database.Resource.Parent.UserNameParameter))
+ .WithEnvironment("KC_DB_PASSWORD", ToRef(password?.Resource ??
+ database.Resource.Parent.PasswordParameter));
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/README.md b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/README.md
new file mode 100644
index 00000000..847cd540
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions/README.md
@@ -0,0 +1,56 @@
+# Keycloak Hosting Extensions for .NET Aspire
+
+## Overview
+
+This package provides **.NET Aspire hosting extensions** for integrating **Keycloak** with your AppHost.
+It includes a PostgreSQL integration that works with resources created via `Aspire.Hosting.Postgres.AddPostgres()` and `Aspire.Hosting.Keycloak.AddKeycloak()`, and automatically configures the required environment variables for Keycloak database connectivity.
+
+---
+
+## Features
+
+* Configure **Keycloak** to use **PostgreSQL**.
+* Automatically sets environment variables:
+ * `KC_DB`
+ * `KC_DB_URL`
+ * `KC_DB_USERNAME`
+ * `KC_DB_PASSWORD`
+* Supports **XA transactions** via `KC_TRANSACTION_XA_ENABLED`.
+* Integrates with **ParameterResource** for secure user/password injection.
+* Falls back to **default credentials** (`postgres` / `postgres`) if no parameters are provided.
+* Fluent **extension methods** on the hosting model.
+
+---
+
+## Usage (AppHost)
+
+```csharp
+var postgres = builder.AddPostgres("pg");
+var db = postgres.AddDatabase("keycloakdb");
+
+// Using explicit username/password parameters
+var user = builder.AddParameter("keycloak-user");
+var pass = builder.AddParameter("keycloak-pass");
+
+var keycloak = builder.AddKeycloak("kc")
+ .WithPostgres(db, user, pass);
+
+// Or rely on server parameters or default postgres/postgres
+var keycloak2 = builder.AddKeycloak("kc2")
+ .WithPostgres(db, xaEnabled: true);
+```
+
+Keycloak will be configured with:
+
+* `KC_DB=postgres`
+* `KC_DB_URL=jdbc:postgresql://:/keycloakdb`
+* `KC_DB_USERNAME=`
+* `KC_DB_PASSWORD=`
+* `KC_TRANSACTION_XA_ENABLED=true` (when set)
+
+---
+
+## Notes
+
+* Extension methods are in the `Aspire.Hosting` namespace for discoverability in AppHost projects.
+* If you add custom resource types, place them under `Aspire.Hosting.ApplicationModel`.
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests.csproj
new file mode 100644
index 00000000..916a2085
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakExtensionTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakExtensionTests.cs
new file mode 100644
index 00000000..2c1072d3
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakExtensionTests.cs
@@ -0,0 +1,121 @@
+using Aspire.Hosting;
+using Moq;
+
+namespace CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests;
+
+public class KeycloakExtensionTests
+{
+ private static async Task> GetEnv(IResourceBuilder kc)
+ {
+ return await kc.Resource.GetEnvironmentVariableValuesAsync();
+ }
+
+ [Fact]
+ public void WithPostgresDev_Should_Throw_If_Builder_Is_Null()
+ {
+ IDistributedApplicationBuilder builder = null!;
+
+ var act = () => builder.AddKeycloak("testkeycloak")
+ .WithPostgres(null!);
+
+ var exeption = Assert.Throws(act);
+ Assert.Equal("builder", exeption.ParamName);
+ }
+
+ [Fact]
+ public void WithPostgresDev_Should_Throw_If_Database_Is_Null()
+ {
+ var app = DistributedApplication.CreateBuilder();
+ Assert.Throws(() =>
+ app.AddKeycloak("testkeycloak")
+ .WithPostgres(null!));
+ }
+
+ [Fact]
+ public void WithPostgres_ExplicitCredentials_NullBuilder_Throws()
+ {
+ Assert.Throws(() =>
+ KeycloakPostgresExtension.WithPostgres(null!,
+ new Mock>().Object,
+ new Mock>().Object,
+ new Mock>().Object));
+ }
+
+ [Fact]
+ public async Task WithPostgres_Defaults_SetBasicVars()
+ {
+ var app = DistributedApplication.CreateBuilder();
+ var pg = app.AddPostgres("pg");
+ var db = pg.AddDatabase("keycloakdb");
+ var kc = app.AddKeycloak("kc")
+ .WithPostgres(db);
+
+ var env = await GetEnv(kc);
+
+ Assert.Equal("postgres", env["KC_DB"]);
+ Assert.True(env.ContainsKey("KC_DB_URL"));
+ var url = env["KC_DB_URL"];
+ Assert.StartsWith("jdbc:postgresql://", url);
+ Assert.EndsWith("/keycloakdb", url);
+ }
+
+ [Fact]
+ public async Task WithPostgres_ExplicitParameters_AreUsed()
+ {
+ var app = DistributedApplication.CreateBuilder();
+
+ var pg = app.AddPostgres("pg");
+ var db = pg.AddDatabase("keycloakdb");
+ var user = app.AddParameter("kc-user");
+ var pass = app.AddParameter("kc-pass");
+
+ var kc = app.AddKeycloak("kc")
+ .WithPostgres(db, user, pass);
+
+ var env = await GetEnv(kc);
+
+ Assert.False(ReferenceEquals(user.Resource, env["KC_DB_USERNAME"]));
+ Assert.False(ReferenceEquals(pass.Resource, env["KC_DB_PASSWORD"]));
+ Assert.True(env.ContainsKey("KC_DB_USERNAME"));
+ Assert.True(env.ContainsKey("KC_DB_PASSWORD"));
+ }
+
+ [Fact]
+ public async Task WithPostgres_Uses_ServerParameters_When_Present()
+ {
+ var app = DistributedApplication.CreateBuilder();
+
+ var pg = app.AddPostgres("pg");
+ var username = app.AddParameter("pg-user");
+ var pass = app.AddParameter("pg-pass");
+
+ pg.WithUserName(username)
+ .WithPassword(pass);
+
+ var db = pg.AddDatabase("keycloakdb");
+
+ var kc = app.AddKeycloak("kc")
+ .WithPostgres(db);
+
+ var env = await GetEnv(kc);
+ Assert.NotEqual("postgres", env["KC_DB_USERNAME"].ToString());
+ Assert.NotEqual("postgres", env["KC_DB_PASSWORD"].ToString());
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task WithPostgres_XA_Flag_Set_When_Enabled(bool xaEnabled)
+ {
+ var app = DistributedApplication.CreateBuilder();
+
+ var pg = app.AddPostgres("pg");
+ var db = pg.AddDatabase("keycloakdb");
+
+ var kc = app.AddKeycloak("kc")
+ .WithPostgres(db, xaEnabled: xaEnabled);
+
+ var env = await GetEnv(kc);
+ Assert.Equal(xaEnabled.ToString().ToLower(), env["KC_TRANSACTION_XA_ENABLED"]);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakWithPostgresIntegrationTest.cs b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakWithPostgresIntegrationTest.cs
new file mode 100644
index 00000000..a295d9f9
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests/KeycloakWithPostgresIntegrationTest.cs
@@ -0,0 +1,84 @@
+using Aspire.Components.Common.Tests;
+using Aspire.Hosting;
+using Aspire.Hosting.Utils;
+
+namespace CommunityToolkit.Aspire.Hosting.Keycloak.Extensions.Tests;
+
+[RequiresDocker]
+public class KeycloakWithPostgresIntegrationTest
+{
+ [Fact]
+ public async Task Keycloak_WithPostgres_Starts_And_HealthReady_Ok()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var pg = builder.AddPostgres("pg");
+ var db = pg.AddDatabase("keycloakdb");
+
+ var kc = builder.AddKeycloak("kc")
+ .WithPostgres(db);
+
+ await using var app = await builder.BuildAsync();
+
+ await app.StartAsync();
+
+ await app.ResourceNotifications.WaitForResourceAsync(pg.Resource.Name, KnownResourceStates.Running)
+ .WaitAsync(TimeSpan.FromMinutes(3));
+ await app.ResourceNotifications.WaitForResourceAsync(kc.Resource.Name, KnownResourceStates.Running)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ await app.ResourceNotifications
+ .WaitForResourceHealthyAsync(pg.Resource.Name)
+ .WaitAsync(TimeSpan.FromMinutes(3));
+
+
+ await app.ResourceNotifications
+ .WaitForResourceHealthyAsync(kc.Resource.Name)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+
+ using var http = app.CreateHttpClient(kc.Resource.Name, "management");
+ var response = await http.GetAsync("/health/ready");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ await app.StopAsync();
+ }
+
+ [Fact]
+ public async Task Keycloak_WithPostgres_Env_Is_Applied_And_DbUrl_Is_Valid()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+
+ var pg = builder.AddPostgres("pg");
+ var db = pg.AddDatabase("keycloakdb");
+
+ var kc = builder.AddKeycloak("kc")
+ .WithPostgres(db);
+
+ await using var app = await builder.BuildAsync();
+ await app.StartAsync();
+
+ await app.ResourceNotifications.WaitForResourceAsync(pg.Resource.Name, KnownResourceStates.Running)
+ .WaitAsync(TimeSpan.FromMinutes(3));
+ await app.ResourceNotifications.WaitForResourceAsync(kc.Resource.Name, KnownResourceStates.Running)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ await app.ResourceNotifications
+ .WaitForResourceHealthyAsync(pg.Resource.Name)
+ .WaitAsync(TimeSpan.FromMinutes(3));
+
+
+ await app.ResourceNotifications
+ .WaitForResourceHealthyAsync(kc.Resource.Name)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ var env = await kc.Resource.GetEnvironmentVariableValuesAsync();
+
+ Assert.Equal("postgres", env["KC_DB"]);
+ Assert.True(env.ContainsKey("KC_DB_URL"));
+ Assert.StartsWith("jdbc:postgresql://", env["KC_DB_URL"]);
+ Assert.EndsWith("/keycloakdb", env["KC_DB_URL"]);
+
+ await app.StopAsync();
+ }
+}
\ No newline at end of file