diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index 73ed3e46d..47b581b6d 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -80,6 +80,12 @@
+
+
+
+
+
+
@@ -214,6 +220,7 @@
+
@@ -239,6 +246,7 @@
+
@@ -276,6 +284,7 @@
+
@@ -300,6 +309,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 668a4938a..da945c68c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -20,7 +20,9 @@
+
+
@@ -32,6 +34,8 @@
+
+
diff --git a/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/AppHost.cs b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/AppHost.cs
new file mode 100644
index 000000000..d87f5048e
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/AppHost.cs
@@ -0,0 +1,23 @@
+using CommunityToolkit.Aspire.Hosting.Logto;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var postgres = builder.AddPostgres("postgres")
+ .WithDataVolume();
+
+var cache = builder.AddRedis("redis")
+ .WithDataVolume();
+
+var logto = builder.AddLogtoContainer("logto", postgres)
+ .WithRedis(cache);
+
+
+var clientOIDC = builder.AddProject("clientOIDC")
+ .WithReference(logto)
+ .WaitFor(logto);
+var clientJWT = builder.AddProject("clientJWT")
+ .WithReference(logto)
+ .WaitFor(logto);
+
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/CommunityToolkit.Aspire.Hosting.Logto.AppHost.csproj b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/CommunityToolkit.Aspire.Hosting.Logto.AppHost.csproj
new file mode 100644
index 000000000..832a2091a
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/CommunityToolkit.Aspire.Hosting.Logto.AppHost.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/Properties/launchSettings.json b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..7a1e3ad1e
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17139;http://localhost:15140",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21242",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23087",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22172"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15140",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19078",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18181",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20004"
+ }
+ }
+ }
+}
diff --git a/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/appsettings.json b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/appsettings.json
new file mode 100644
index 000000000..31c092aa4
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Logto.ServiceDefaults.csproj b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Logto.ServiceDefaults.csproj
new file mode 100644
index 000000000..75be9da31
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Logto.ServiceDefaults.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.ServiceDefaults/Extensions.cs b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.ServiceDefaults/Extensions.cs
new file mode 100644
index 000000000..9a2ef56a7
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.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.Logging;
+using Microsoft.Extensions.ServiceDiscovery;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+// Adds common 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/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/CommunityToolkit.Aspire.Hosting.Logto.ClientJWT.http b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/CommunityToolkit.Aspire.Hosting.Logto.ClientJWT.http
new file mode 100644
index 000000000..a4e1a7dc0
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/CommunityToolkit.Aspire.Hosting.Logto.ClientJWT.http
@@ -0,0 +1,6 @@
+@CommunityToolkit.Aspire.Hosting.Logto.ClientJWT_HostAddress = http://localhost:5072
+
+GET {{CommunityToolkit.Aspire.Hosting.Logto.ClientJWT_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/CommunityToolkit.Aspire.Logto.ClientJWT.csproj b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/CommunityToolkit.Aspire.Logto.ClientJWT.csproj
new file mode 100644
index 000000000..a0ef7dc03
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/CommunityToolkit.Aspire.Logto.ClientJWT.csproj
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Program.cs b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Program.cs
new file mode 100644
index 000000000..db14f4123
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Program.cs
@@ -0,0 +1,34 @@
+using CommunityToolkit.Aspire.Logto.Client;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authorization;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
+const string apiAudience = "http://localhost:5072/";
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddLogtoJwtBearer("logto", appIdentification: apiAudience,
+ configureOptions: opt =>
+ {
+ opt.RequireHttpsMetadata = false;
+ });
+
+builder.Services.AddAuthorization();
+
+
+var app = builder.Build();
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.MapGet("/", () => "OK");
+
+app.MapGet("/secure", [Authorize] (System.Security.Claims.ClaimsPrincipal user) =>
+{
+ return new
+ {
+ user.Identity?.Name,
+ Claims = user.Claims.Select(c => new { c.Type, c.Value })
+ };
+});
+
+
+app.Run();
\ No newline at end of file
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Properties/launchSettings.json b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Properties/launchSettings.json
new file mode 100644
index 000000000..59456df8e
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/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:5072",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7138;http://localhost:5072",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/appsettings.json b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/CommunityToolkit.Aspire.Hosting.Logto.Client.http b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/CommunityToolkit.Aspire.Hosting.Logto.Client.http
new file mode 100644
index 000000000..776076030
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/CommunityToolkit.Aspire.Hosting.Logto.Client.http
@@ -0,0 +1,6 @@
+@CommunityToolkit.Aspire.Hosting.Logto.Client_HostAddress = http://localhost:5137
+
+GET {{CommunityToolkit.Aspire.Hosting.Logto.Client_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/CommunityToolkit.Aspire.Logto.ClientOIDC.csproj b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/CommunityToolkit.Aspire.Logto.ClientOIDC.csproj
new file mode 100644
index 000000000..44f15918a
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/CommunityToolkit.Aspire.Logto.ClientOIDC.csproj
@@ -0,0 +1,12 @@
+
+
+ CommunityToolkit.Aspire.Hosting.Logto.Client
+
+
+
+
+
+
+
+
+
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/Program.cs b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/Program.cs
new file mode 100644
index 000000000..2afda04aa
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/Program.cs
@@ -0,0 +1,76 @@
+using CommunityToolkit.Aspire.Logto.Client;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using System.Security.Claims;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
+
+builder.AddLogtoOIDC("logto", logtoOptions: config =>
+{
+ config.AppId = "s6zda5bqn1qlsjzaiklqn";
+ config.AppSecret = "Df77aDt13MG3nSTgo8eKZP2HdeSfbed0";
+
+},oidcOptions: opt =>
+{
+ opt.RequireHttpsMetadata = false;
+});
+builder.Services.AddAuthorization();
+
+var app = builder.Build();
+
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+
+
+
+app.MapGet("/", () => "Hello World!");
+
+app.MapGet("/me",
+ [Authorize](ClaimsPrincipal user) => new
+ {
+ Name = user.Identity?.Name,
+ IsAuthenticated = user.Identity?.IsAuthenticated ?? false,
+ Claims = user.Claims.Select(c => new { c.Type, c.Value })
+ })
+ .WithName("Me");
+
+app.MapGet("/signin", async context =>
+{
+ if (!(context.User?.Identity?.IsAuthenticated ?? false))
+ {
+ await context.ChallengeAsync(new AuthenticationProperties { RedirectUri = "/me" });
+ }
+ else
+ {
+ context.Response.Redirect("/me");
+ }
+});
+app.MapGet("/tokens", [Authorize] async (HttpContext ctx) =>
+{
+ var accessToken = await ctx.GetTokenAsync("access_token");
+ var idToken = await ctx.GetTokenAsync("id_token");
+ var refreshToken = await ctx.GetTokenAsync("refresh_token");
+
+ return Results.Ok(new
+ {
+ access_token = accessToken,
+ id_token = idToken,
+ refresh_token = refreshToken
+ });
+});
+
+app.MapGet("/signout", async context =>
+{
+ if (context.User?.Identity?.IsAuthenticated ?? false)
+ {
+ await context.SignOutAsync(new AuthenticationProperties { RedirectUri = "/" });
+ }
+ else
+ {
+ context.Response.Redirect("/");
+ }
+});
+app.Run();
\ No newline at end of file
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/Properties/launchSettings.json b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/Properties/launchSettings.json
new file mode 100644
index 000000000..efc6b086a
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/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:5137",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7288;http://localhost:5137",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/appsettings.json b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/appsettings.json
new file mode 100644
index 000000000..10f68b8c8
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/CommunityToolkit.Aspire.Hosting.Logto.csproj b/src/CommunityToolkit.Aspire.Hosting.Logto/CommunityToolkit.Aspire.Hosting.Logto.csproj
new file mode 100644
index 000000000..a4e35450f
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/CommunityToolkit.Aspire.Hosting.Logto.csproj
@@ -0,0 +1,15 @@
+
+
+
+
+ .NET Aspire hosting extensions for Logto (includes PostgreSQL and Redis integration).
+ logto redis postgres hosting extensions
+
+
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoBuilderExtensions.cs
new file mode 100644
index 000000000..bc90a11a1
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoBuilderExtensions.cs
@@ -0,0 +1,232 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CommunityToolkit.Aspire.Hosting.Logto;
+
+///
+/// Provides extension methods for configuring and managing Logto container resources
+/// within the Aspire hosting application framework.
+///
+public static class LogtoBuilderExtensions
+{
+ ///
+ /// Adds a Logto container resource to the Aspire distributed application by configuring it
+ /// with the specified name, associated PostgreSQL server resource, and database name.
+ ///
+ /// The distributed application builder to which the Logto container resource will be added.
+ /// The name of the Logto container resource.
+ /// The resource builder for the PostgreSQL server that the Logto container will connect to.
+ /// The resource builder configured for the added Logto container resource.
+ public static IResourceBuilder AddLogtoContainer(
+ this IDistributedApplicationBuilder builder,
+ string name,
+ IResourceBuilder postgres)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentNullException.ThrowIfNull(postgres);
+
+
+ var resource = new LogtoContainerResource(name);
+ var builderWithResource = builder
+ .AddResource(resource)
+ .WithImage(LogtoContainerTags.Image, LogtoContainerTags.Tag)
+ .WithImageRegistry(LogtoContainerTags.Registry);
+
+ builderWithResource.WithResourcePort();
+ builderWithResource.WithDatabase(postgres);
+ SetHealthCheck(builder, builderWithResource, name);
+
+
+ builderWithResource
+ .WithEntrypoint("sh")
+ .WithArgs("-c", "npm run cli db seed -- --swe && npm start");
+
+
+ return builderWithResource;
+ }
+
+ ///
+ /// Enables Node.js deprecation tracing for the Logto container by setting the
+ /// NODE_OPTIONS environment variable to '--trace-deprecation'.
+ /// This allows stack traces to be printed for deprecated API usage.
+ ///
+ /// The resource builder for the Logto container resource that will be configured for stack trace logging.
+ public static void WithDeprecationTracing(this IResourceBuilder builderWithResource)
+ {
+ builderWithResource.WithEnvironment("NODE_OPTIONS", "--trace-deprecation");
+ }
+
+ private static void SetHealthCheck(IDistributedApplicationBuilder builder,
+ IResourceBuilder builderWithResource, string name)
+ {
+ var endpoint = builderWithResource.Resource.GetEndpoint(LogtoContainerResource.PrimaryEndpointName);
+ var healthCheckKey = $"{name}_check";
+ builder.Services.AddHealthChecks()
+ .AddUrlGroup(opt =>
+ {
+ var uri = new Uri(endpoint.Url);
+ opt.AddUri(new Uri(uri, "/api/status"), setup => setup.ExpectHttpCode(204));
+ }, healthCheckKey);
+ builderWithResource.WithHealthCheck(healthCheckKey);
+ }
+
+ /// The resource builder for the Logto container resource to be configured.
+ extension(IResourceBuilder builder)
+ {
+ ///
+ /// Configures the Logto container resource to use the specified Node.js environment value
+ /// by setting the corresponding environment variable.
+ ///
+ /// The value of the Node.js environment variable to set, typically "development", "production", or "test".
+ /// The resource builder for the configured Logto container resource.
+ public IResourceBuilder WithNodeEnv(string env)
+ {
+ return builder.WithEnvironment("NODE_ENV", env);
+ }
+
+ ///
+ /// Configures the Logto container resource with a data volume, allowing persistent storage
+ /// for the container.
+ ///
+ /// The optional name of the data volume. If not provided, a default name is generated.
+ /// The resource builder configured with the specified data volume.
+ public IResourceBuilder WithDataVolume(string? name = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), "/data");
+ }
+
+
+ ///
+ /// Configures HTTP endpoints for the given Logto container resource builder with specified port settings.
+ ///
+ /// The HTTP port to be configured for the primary endpoint. Defaults to the Logto container's default HTTP port.
+ /// The HTTP port to be configured for the administrative endpoint. Defaults to the Logto container's default HTTP admin port.
+ /// The updated resource builder with the configured HTTP endpoints.
+ public IResourceBuilder WithResourcePort(
+ int port = LogtoContainerResource.DefaultHttpPort,
+ int adminPort = LogtoContainerResource.DefaultHttpAdminPort)
+ {
+ return builder.WithHttpEndpoint(port, port,
+ name: LogtoContainerResource.PrimaryEndpointName)
+ .WithHttpEndpoint(adminPort, adminPort,
+ name: LogtoContainerResource.AdminEndpointName);
+ }
+
+ ///
+ /// Configures the specified Logto container resource to include an administrative endpoint
+ /// with the given URL.
+ ///
+ /// The URL of the administrative endpoint to be used for the Logto container resource.
+ /// The resource builder for the configured Logto container resource.
+ public IResourceBuilder WithAdminEndpoint(string url)
+ {
+ //example: https://admin.domain.com
+ return builder.WithEnvironment("ADMIN_ENDPOINT", url);
+ }
+
+ ///
+ /// Configures the Logto container resource to disable the Admin Console port.
+ /// When set to true and ADMIN_ENDPOINT is unset, it will completely disable the Admin Console.
+ ///
+ ///
+ /// A boolean value indicating whether to disable the Admin Console port.
+ /// Set to true to disable the port for Admin Console; otherwise, false.
+ /// With ADMIN_ENDPOINT unset, setting this to true will completely disable the Admin Console.
+ ///
+ /// The resource builder for the configured Logto container resource.
+ public IResourceBuilder WithDisableAdminConsole(bool disable)
+ {
+ return builder.WithEnvironment("ADMIN_DISABLE_LOCALHOST", disable.ToString());
+ }
+
+ ///
+ /// Configures the Logto container resource to enable or disable the trust proxy header behavior
+ /// based on the specified value.
+ ///
+ ///
+ /// A boolean value indicating whether to trust the proxy header.
+ /// Set to true to trust the proxy header; otherwise, false.
+ ///
+ /// The resource builder for the configured Logto container resource.
+ public IResourceBuilder WithTrustProxyHeader(bool trustProxyHeader)
+ {
+ return builder.WithEnvironment("TRUST_PROXY_HEADER", trustProxyHeader.ToString());
+ }
+
+ ///
+ /// Specifies whether the username is case-sensitive.
+ ///
+ /// A value indicating whether usernames should be treated as case-sensitive.
+ /// The updated resource builder with the configured case-sensitivity setting.
+ public IResourceBuilder WithSensitiveUsername(bool sensitiveUsername)
+ {
+ return builder.WithEnvironment("CASE_SENSITIVE_USERNAME", sensitiveUsername.ToString());
+ }
+
+ ///
+ /// Configures the Logto container resource to use a secret vault with the specified key encryption key (KEK).
+ /// The KEK is used to encrypt Data Encryption Keys (DEK) in the Secret Vault and must be a base64-encoded string.
+ /// AES-256 (32 bytes) is recommended. Example: crypto.randomBytes(32).toString('base64')
+ ///
+ /// The base64-encoded key encryption key (KEK) for the secret vault. Must be base64-encoded; AES-256 (32 bytes) is recommended.
+ public IResourceBuilder WithSecretVault(string secretVaultKek)
+ {
+ return builder.WithEnvironment("SECRET_VAULT_KEK", secretVaultKek);
+ }
+
+ ///
+ /// Configures the Logto container resource to use a data bind mount with the specified
+ /// source directory as the data volume for the container.
+ ///
+ /// The host directory to be mounted as the container's data volume.
+ /// The resource builder for the configured Logto container resource.
+ public IResourceBuilder WithDataBindMount(string source)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(source);
+
+ return builder.WithBindMount(source, "/data");
+ }
+
+ ///
+ /// Configures the Logto container resource to use a specified Redis resource for caching or other functionality
+ /// by setting the REDIS_URL environment variable and establishing a dependency on the Redis resource.
+ ///
+ /// The resource builder for the Redis resource to be used by the Logto container resource.
+ /// The resource builder configured with the specified Redis resource.
+ public IResourceBuilder WithRedis(IResourceBuilder redis)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(redis);
+
+ return builder.WithEnvironment("REDIS_URL", redis.Resource.UriExpression)
+ .WaitFor(redis);
+ }
+
+ ///
+ /// Configures the Logto container resource to connect to the specified PostgreSQL database
+ /// by setting the appropriate environment variables and establishing a dependency on the database resource.
+ ///
+ /// The resource builder for the PostgreSQL server to connect to.
+ /// The resource builder for the configured Logto container resource.
+ public IResourceBuilder WithDatabase(IResourceBuilder postgres)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(postgres);
+
+ var dbUrlBuilder = new ReferenceExpressionBuilder();
+ //I don't why actually db must be logto_db
+ dbUrlBuilder.Append($"{postgres!.Resource.UriExpression}/logto_db");
+ var dbUrl = dbUrlBuilder.Build();
+
+
+ builder.WithEnvironment("DB_URL", dbUrl)
+ .WaitFor(postgres);
+ return builder;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoContainerResource.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoContainerResource.cs
new file mode 100644
index 000000000..efec2668b
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoContainerResource.cs
@@ -0,0 +1,66 @@
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Logto;
+
+///
+/// Represents a containerized resource specific to Logto that extends the base functionality
+/// of container resources by providing additional endpoint and connection string management.
+///
+///
+/// This class is designed for use in an application hosting environment and incorporates
+/// a primary HTTP endpoint with predefined default port configurations.
+///
+public sealed class LogtoContainerResource(string name)
+ : ContainerResource(name), IResourceWithConnectionString
+{
+ internal const string PrimaryEndpointName = "http";
+ internal const string AdminEndpointName = "admin";
+ internal const int DefaultHttpPort = 3001;
+ internal const int DefaultHttpAdminPort = 3002;
+
+
+ /// Gets the primary endpoint associated with the container resource.
+ /// This property provides a reference to the primary HTTP endpoint for the resource,
+ /// facilitating network communication and identifying the primary access point.
+ /// The endpoint is tied to the default configuration for HTTP-based interactions
+ /// and is predefined with a specific protocol and port settings.
+ public EndpointReference PrimaryEndpoint => new(this, PrimaryEndpointName);
+
+ /// Gets the host associated with the primary endpoint of the container resource.
+ /// This property allows access to the host definition of the primary HTTP endpoint,
+ /// which is referenced by using the `EndpointProperty.Host`.
+ /// The Host provides necessary information for identifying the network address
+ /// or location of the primary endpoint associated with this container resource.
+ public EndpointReferenceExpression Host => PrimaryEndpoint.Property(EndpointProperty.Host);
+
+ /// Gets the port number associated with the primary HTTP endpoint of this resource.
+ /// This property represents the port component of the endpoint where the resource
+ /// is accessible. It is derived from the `PrimaryEndpoint` and corresponds to the
+ /// value of the `Port` property in the endpoint configuration.
+ /// The port is typically used to distinguish network services on the same host
+ /// and is crucial in forming a valid connection string or URL for resource access.
+ /// For example, this value may represent a default port for the application or
+ /// a specific port explicitly configured for the resource's endpoint.
+ /// This property is especially relevant when constructing connection strings or
+ /// when validation of the endpoint's configuration is required.
+ public EndpointReferenceExpression Port => PrimaryEndpoint.Property(EndpointProperty.Port);
+
+
+ private ReferenceExpression GetConnectionString()
+ {
+ var builder = new ReferenceExpressionBuilder();
+
+ builder.Append(
+ $"Endpoint={PrimaryEndpointName}://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}");
+
+ return builder.Build();
+ }
+
+ /// Gets the connection string expression for the Logto container resource.
+ /// The connection string is dynamically constructed based on the resource's
+ /// endpoint configuration and includes details such as the protocol, host,
+ /// and port. This property provides a reference to the connection string,
+ /// allowing integration with external resources or clients requiring
+ /// connection details formatted as a string expression.
+ public ReferenceExpression ConnectionStringExpression => GetConnectionString();
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoContainerTags.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoContainerTags.cs
new file mode 100644
index 000000000..29337a40e
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoContainerTags.cs
@@ -0,0 +1,16 @@
+namespace CommunityToolkit.Aspire.Hosting.Logto;
+
+///
+/// Represents a collection of constants for container tags related to the Logto application.
+///
+public class LogtoContainerTags
+{
+ /// docker.io
+ public const string Registry = "docker.io";
+
+ /// svhd/logto
+ public const string Image = "svhd/logto";
+
+ /// 1.34
+ public const string Tag = "1.34";
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/README.md b/src/CommunityToolkit.Aspire.Hosting.Logto/README.md
new file mode 100644
index 000000000..bebc97886
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/README.md
@@ -0,0 +1,69 @@
+# Logto Hosting Extensions for .NET Aspire
+
+## Overview
+
+This package provides **.NET Aspire hosting extensions** for integrating **Logto** with your AppHost.
+It includes helpers for wiring Logto to **PostgreSQL** (via `Aspire.Hosting.Postgres.AddPostgres()`) and optional **Redis** caching, and exposes fluent APIs to configure the required environment variables for Logto database connectivity, initialization, and caching.
+
+---
+
+## Features
+
+- Configure **Logto** to use **PostgreSQL** via `AddLogtoContainer(...)`.
+- Optional **Redis** integration for caching via `.WithRedis(...)`.
+- Fluent helpers to set environment variables:
+ - `DB_URL` (Postgres connection string)
+ - `REDIS_URL`
+ - `NODE_ENV`
+ - `ADMIN_ENDPOINT`
+- Data persistence via:
+ - `.WithDataVolume()` (managed Docker volume)
+ - `.WithDataBindMount()` (host bind mount).
+- Configurable **Admin Console** access and **proxy header** trust (`TRUST_PROXY_HEADER`).
+- Built-in health check for `/api/status`.
+
+---
+
+## Usage (AppHost)
+
+```csharp
+using Aspire.Hosting;
+using Aspire.Hosting.Postgres;
+using CommunityToolkit.Aspire.Hosting.Logto;
+
+var postgres = builder.AddPostgres("postgres");
+
+// Basic setup connecting to Postgres
+var logto = builder
+ .AddLogtoContainer("logto", postgres, databaseName: "logto_db")
+ .WithDataVolume();
+
+// Advanced setup with Redis and specific configurations
+var redis = builder.AddRedis("redis");
+
+var logtoSecure = builder
+ .AddLogtoContainer("logto-secure", postgres, databaseName: "logto_secure_db")
+ .WithRedis(redis)
+ .WithAdminEndpoint("https://admin.example.com")
+ .WithDisableAdminConsole(false)
+ .WithTrustProxyHeader(true) // optional override, default is already true
+ .WithSensitiveUsername(true)
+ .WithNodeEnv("production");
+````
+
+Logto will be configured with:
+
+* `DB_URL=postgresql://.../logto_db` (constructed from the Postgres resource)
+* `REDIS_URL=...` (when Redis is attached with `.WithRedis(...)`)
+* `ADMIN_ENDPOINT=...` (when configured with `.WithAdminEndpoint(...)`)
+* `NODE_ENV=production` (when configured with `.WithNodeEnv(...)`)
+* Auto-configured health checks on `/api/status`.
+
+---
+
+## Notes
+
+* Extension methods are in the `CommunityToolkit.Aspire.Hosting.Logto` namespace.
+* The resource automatically runs the database seeding command
+ `npm run cli db seed -- --swe && npm start` on startup.
+* Default ports are **3001** (HTTP) and **3002** (Admin).
diff --git a/src/CommunityToolkit.Aspire.Logto.Client/CommunityToolkit.Aspire.Logto.Client.csproj b/src/CommunityToolkit.Aspire.Logto.Client/CommunityToolkit.Aspire.Logto.Client.csproj
new file mode 100644
index 000000000..431101722
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Logto.Client/CommunityToolkit.Aspire.Logto.Client.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/CommunityToolkit.Aspire.Logto.Client/LogtoClientBuilder.cs b/src/CommunityToolkit.Aspire.Logto.Client/LogtoClientBuilder.cs
new file mode 100644
index 000000000..38e7d5b95
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Logto.Client/LogtoClientBuilder.cs
@@ -0,0 +1,184 @@
+using Logto.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.IdentityModel.Tokens;
+
+namespace CommunityToolkit.Aspire.Logto.Client;
+
+///
+/// Provides methods to configure and add Logto client services to an application builder.
+///
+public static class LogtoClientBuilder
+{
+ private const string DefaultConfigSectionName = "Aspire:Logto:Client";
+
+
+ ///
+ /// Configures and adds the Logto OpenID Connect (OIDC) authentication for the specified application's service collection.
+ ///
+ /// The application builder used to configure the application's services and pipeline.
+ /// The name of the connection configuration to be used. If null, a default connection is used.
+ ///
+ /// The name of the configuration section that contains Logto client settings.
+ /// Defaults to "Aspire:Logto:Client".
+ ///
+ ///
+ /// The authentication scheme identifier for the Logto OIDC authentication. Defaults to "Logto".
+ ///
+ ///
+ /// The cookie scheme name to be used with the Logto OIDC authentication. Defaults to "Logto.Cookie".
+ ///
+ ///
+ /// A delegate to configure Logto-specific options such as endpoint, application ID, or secret.
+ ///
+ ///
+ /// A delegate to configure OpenID Connect options for fine-tuning authentication behavior.
+ ///
+ ///
+ /// An updated instance with the configured Logto OIDC authentication.
+ ///
+ /// Thrown when the builder is null.
+ public static IServiceCollection AddLogtoOIDC(this IHostApplicationBuilder builder,
+ string? connectionName = null,
+ string? configurationSectionName = DefaultConfigSectionName,
+ string authenticationScheme = "Logto",
+ string cookieScheme = "Logto.Cookie",
+ Action? logtoOptions = null,
+ Action? oidcOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ var options = GetEndpoint(builder.Configuration, configurationSectionName, connectionName);
+
+ builder.Services.AddLogtoAuthentication(authenticationScheme, cookieScheme, opt =>
+ {
+ opt.Endpoint = options.Endpoint;
+ opt.AppId = options.AppId;
+ opt.AppSecret = options.AppSecret;
+ logtoOptions?.Invoke(opt);
+ });
+ builder.Services.Configure(authenticationScheme, opt =>
+ {
+ oidcOptions?.Invoke(opt);
+ });
+ return builder.Services;
+ }
+
+ ///
+ /// Configures and adds the Logto JSON Web Token (JWT) Bearer authentication to the specified authentication builder.
+ ///
+ /// The authentication builder used to configure authentication services.
+ /// The name of the Logto service instance to be used for authentication.
+ /// A collection of application identifiers associated with the Logto service.
+ ///
+ /// The authentication scheme identifier for the Logto JWT Bearer authentication. Defaults to "Bearer".
+ ///
+ ///
+ /// The name of the configuration section that contains Logto client settings. Defaults to "Aspire:Logto:Client".
+ ///
+ ///
+ /// A delegate to configure the options for JWT bearer authentication.
+ ///
+ ///
+ /// An updated instance with the configured Logto JWT Bearer authentication.
+ ///
+ /// Thrown when the builder or serviceName is null.
+ public static AuthenticationBuilder AddLogtoJwtBearer(this AuthenticationBuilder builder,
+ string serviceName,
+ string appIdentification,
+ string authenticationScheme = JwtBearerDefaults.AuthenticationScheme,
+ string? configurationSectionName = DefaultConfigSectionName,
+ Action? configureOptions = null)
+ {
+ return AddLogtoJwtBearer(builder, serviceName, [appIdentification], authenticationScheme,
+ configurationSectionName, configureOptions);
+ }
+
+
+ ///
+ /// Configures and adds the Logto JSON Web Token (JWT) Bearer authentication for the specified application.
+ ///
+ ///
+ /// The authentication builder used to configure the application's authentication services.
+ ///
+ ///
+ /// The name of the service associated with Logto configuration.
+ ///
+ ///
+ /// A collection of application identifiers (audiences) used to validate the JWT's audience claim.
+ ///
+ ///
+ /// The authentication scheme identifier for the Logto JWT Bearer authentication. Defaults to "Bearer".
+ ///
+ ///
+ /// The name of the configuration section that contains Logto client settings. Defaults to "Aspire:Logto:Client".
+ ///
+ ///
+ /// A delegate to configure additional JwtBearerOptions as needed for specific authentication behavior.
+ ///
+ ///
+ /// An updated instance with the configured Logto JWT Bearer authentication.
+ ///
+ /// Thrown when the builder is null.
+ ///
+ /// Thrown when serviceName or appIndeficator is missing or invalid.
+ ///
+ public static AuthenticationBuilder AddLogtoJwtBearer(this AuthenticationBuilder builder,
+ string serviceName,
+ IEnumerable appIdentification,
+ string authenticationScheme = JwtBearerDefaults.AuthenticationScheme,
+ string? configurationSectionName = DefaultConfigSectionName,
+ Action? configureOptions = null)
+ {
+ builder.Services
+ .AddOptions(authenticationScheme)
+ .Configure((jwt, configuration) =>
+ {
+ var logto = GetEndpoint(configuration, configurationSectionName, serviceName);
+ var issuer = logto.Endpoint.TrimEnd('/') + "/oidc";
+
+ jwt.Authority = issuer;
+ jwt.TokenValidationParameters = new TokenValidationParameters()
+ {
+ ValidateIssuer = true,
+ ValidIssuer = issuer,
+ ValidateAudience = true,
+ ValidAudiences = appIdentification
+ };
+ configureOptions?.Invoke(jwt);
+ });
+ builder.AddJwtBearer(authenticationScheme);
+ return builder;
+ }
+
+ private static LogtoOptions GetEndpoint(IConfiguration configuration, string? configurationSectionName,
+ string? connectionName)
+ {
+ var options = new LogtoOptions();
+
+ var sectionName = configurationSectionName ?? DefaultConfigSectionName;
+ configuration.GetSection(sectionName).Bind(options);
+
+ if (!string.IsNullOrEmpty(connectionName) &&
+ configuration.GetConnectionString(connectionName) is { } cs)
+ {
+ var endpointFromCs = LogtoConnectionStringHelper.GetEndpointFromConnectionString(cs);
+
+ if (!string.IsNullOrWhiteSpace(endpointFromCs) &&
+ string.IsNullOrWhiteSpace(options.Endpoint))
+ {
+ options.Endpoint = endpointFromCs;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(options.Endpoint))
+ throw new InvalidOperationException(
+ $"Logto Endpoint must be configured in configuration section '{sectionName}' or via configureOptions.");
+
+ return options;
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs b/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs
new file mode 100644
index 000000000..1c1e0bb66
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs
@@ -0,0 +1,47 @@
+using System.Data.Common;
+
+namespace CommunityToolkit.Aspire.Logto.Client;
+
+///
+/// Provides utility methods for extracting and validating endpoint information
+/// from connection strings in various formats. This helper is specifically designed
+/// to assist with parsing connection strings for use with the Logto client configuration.
+///
+public class LogtoConnectionStringHelper
+{
+ private const string ConnectionStringEndpointKey = "Endpoint";
+
+ ///
+ /// Retrieves the endpoint value from a given connection string. If the connection string is a valid URI,
+ /// the method returns the URI as a string. If the connection string is in a key-value pair format,
+ /// it extracts the value of the "Endpoint" key if present and validates it as a URI.
+ ///
+ /// The connection string to parse for an endpoint.
+ ///
+ /// A string representation of the endpoint if found and valid; otherwise, null if the connection
+ /// string is null, empty, or does not contain a valid endpoint.
+ ///
+ public static string? GetEndpointFromConnectionString(string? connectionString)
+ {
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ return null;
+ }
+
+ if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri))
+ {
+ return uri.ToString();
+ }
+
+ var builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
+
+ if (builder.TryGetValue(ConnectionStringEndpointKey, out var endpointObj) &&
+ endpointObj is string endpoint &&
+ Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri))
+ {
+ return endpointUri.ToString();
+ }
+
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/AppHostTest.cs b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/AppHostTest.cs
new file mode 100644
index 000000000..86e846537
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/AppHostTest.cs
@@ -0,0 +1,30 @@
+using Aspire.Components.Common.Tests;
+using Aspire.Hosting;
+using CommunityToolkit.Aspire.Testing;
+
+namespace CommunityToolkit.Aspire.Hosting.Logto.Tests;
+
+[RequiresDocker]
+public class AppHostTest
+{
+ [Fact]
+ public async Task LogtoResourceStartsAndRespondsOk()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var postgres = builder.AddPostgres("postgres");
+ var logto = builder.AddLogtoContainer("logto", postgres);
+
+ using var app = builder.Build();
+ var rns = app.Services.GetRequiredService();
+
+ await app.StartAsync();
+
+ // Wait for the resource to be healthy
+ await rns.WaitForResourceHealthyAsync(logto.Resource.Name).WaitAsync(TimeSpan.FromMinutes(5));
+
+ var httpClient = app.CreateHttpClient(logto.Resource.Name);
+ var response = await httpClient.GetAsync("/");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/CommunityToolkit.Aspire.Hosting.Logto.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/CommunityToolkit.Aspire.Hosting.Logto.Tests.csproj
new file mode 100644
index 000000000..3c8568429
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/CommunityToolkit.Aspire.Hosting.Logto.Tests.csproj
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/ResourceCreationTests.cs
new file mode 100644
index 000000000..6aef836fa
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/ResourceCreationTests.cs
@@ -0,0 +1,44 @@
+using Aspire.Hosting;
+
+namespace CommunityToolkit.Aspire.Hosting.Logto.Tests;
+
+public class ResourceCreationTests
+{
+ [Fact]
+ public void LogtoResourceGetsAdded()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var postgres = builder.AddPostgres("postgres");
+
+ builder.AddLogtoContainer("logto", postgres);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ Assert.Equal("logto", resource.Name);
+ }
+
+ [Fact]
+ public void LogtoResourceHealthChecks()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var postgres = builder.AddPostgres("postgres");
+
+ builder.AddLogtoContainer("logto", postgres);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ var result = resource.TryGetAnnotationsOfType(out var annotations);
+ Assert.True(result);
+ Assert.NotNull(annotations);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/CommunityToolkit.Aspire.Logto.Client.Tests.csproj b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/CommunityToolkit.Aspire.Logto.Client.Tests.csproj
new file mode 100644
index 000000000..f161242a6
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/CommunityToolkit.Aspire.Logto.Client.Tests.csproj
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderIntegrationTests.cs b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderIntegrationTests.cs
new file mode 100644
index 000000000..bbde24f0d
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderIntegrationTests.cs
@@ -0,0 +1,123 @@
+using Logto.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+
+namespace CommunityToolkit.Aspire.Logto.Client.Tests;
+
+public class LogtoClientBuilderIntegrationTests
+{
+ private static WebApplicationBuilder CreateBuilderWithBaseConfig(
+ Dictionary? extraConfig = null)
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ var config = new Dictionary
+ {
+ ["Aspire:Logto:Client:Endpoint"] = "https://logto.example.com",
+ ["Aspire:Logto:Client:AppId"] = "test-app-id",
+ ["Aspire:Logto:Client:AppSecret"] = "test-secret"
+ };
+
+ if (extraConfig is not null)
+ {
+ foreach (var kv in extraConfig)
+ {
+ config[kv.Key] = kv.Value;
+ }
+ }
+
+ builder.Configuration.AddInMemoryCollection(config);
+
+ return builder;
+ }
+
+ [Fact]
+ public async Task AddLogtoOIDC_RegistersLogtoAuthenticationScheme()
+ {
+ // Arrange
+ var builder = CreateBuilderWithBaseConfig();
+
+ // Act
+ builder.AddLogtoOIDC();
+ using var host = builder.Build();
+
+ // Assert
+ var schemes = host.Services.GetRequiredService();
+ var scheme = await schemes.GetSchemeAsync("Logto");
+
+ Assert.NotNull(scheme);
+ Assert.Equal("Logto", scheme!.Name);
+ }
+
+ [Fact]
+ public async Task AddLogtoOIDC_AllowsOverrideOfAuthenticationScheme()
+ {
+ // Arrange
+ var builder = CreateBuilderWithBaseConfig();
+ const string customScheme = "MyLogto";
+
+ // Act
+ builder.AddLogtoOIDC(authenticationScheme: customScheme);
+ using var host = builder.Build();
+
+ // Assert
+ var schemes = host.Services.GetRequiredService();
+ var scheme = await schemes.GetSchemeAsync(customScheme);
+
+ Assert.NotNull(scheme);
+ Assert.Equal(customScheme, scheme!.Name);
+ }
+
+ [Fact]
+ public void AddLogtoSDKClient_UsesConnectionStringEndpoint_WhenSectionEndpointMissing()
+ {
+ // Arrange: удаляем Endpoint из секции, оставляем только в connection string
+ var extraConfig = new Dictionary
+ {
+ ["Aspire:Logto:Client:Endpoint"] = null,
+ ["ConnectionStrings:Logto"] = "Endpoint=https://logto-from-cs.example.com"
+ };
+
+ var builder = CreateBuilderWithBaseConfig(extraConfig);
+
+ // Act
+ builder.AddLogtoOIDC(connectionName: "Logto");
+ using var host = builder.Build();
+
+ // Assert: как минимум убедимся, что всё собралось
+ // и LogtoOptions вообще зарегистрированы (если библиотека их регистрирует)
+ var optionsMonitor = host.Services.GetService>();
+ Assert.NotNull(optionsMonitor);
+
+ var options = optionsMonitor!.Get("Logto"); // имя схемы
+ Assert.StartsWith("https://logto-from-cs.example.com", options.Endpoint);
+ Assert.Equal("test-app-id", options.AppId);
+ Assert.Equal("test-secret", options.AppSecret);
+ }
+
+ [Fact]
+ public void AddLogtoClient_ConfigureSettings_CanOverrideOptions()
+ {
+ // Arrange
+ var builder = CreateBuilderWithBaseConfig();
+
+ // Act
+ builder.AddLogtoOIDC(logtoOptions: opt =>
+ {
+ opt.Endpoint = "https://overridden.example.com";
+ opt.AppId = "overridden-app-id";
+ });
+
+ using var host = builder.Build();
+
+ // Assert
+ var optionsMonitor = host.Services.GetService>();
+ Assert.NotNull(optionsMonitor);
+
+ var options = optionsMonitor!.Get("Logto");
+ Assert.StartsWith("https://overridden.example.com", options.Endpoint);
+ Assert.Equal("overridden-app-id", options.AppId);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderTests.cs b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderTests.cs
new file mode 100644
index 000000000..055da1058
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderTests.cs
@@ -0,0 +1,110 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+
+namespace CommunityToolkit.Aspire.Logto.Client.Tests;
+
+public class LogtoClientBuilderTests
+{
+ [Fact]
+ public void AddLogtoSDKClient_ThrowsArgumentNull_WhenBuilderIsNull()
+ {
+ IHostApplicationBuilder? builder = null;
+
+ var ex = Assert.Throws(() =>
+ builder!.AddLogtoOIDC());
+
+ Assert.Equal("builder", ex.ParamName);
+ }
+
+ [Fact]
+ public void AddLogtoSDKClient_ThrowsInvalidOperation_WhenEndpointNotConfiguredAnywhere()
+ {
+ var builder = Host.CreateApplicationBuilder();
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ //empty
+ });
+
+ var ex = Assert.Throws(() =>
+ builder.AddLogtoOIDC());
+
+ Assert.Contains("Logto Endpoint must be configured", ex.Message);
+ }
+
+ [Fact]
+ public void AddLogtoSDKClient_UsesEndpointFromConfiguration_WhenPresent()
+ {
+ var builder = Host.CreateApplicationBuilder();
+
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["Aspire:Logto:Client:Endpoint"] = "https://logto-config.example.com",
+ ["Aspire:Logto:Client:AppId"] = "test-app-id",
+ ["Aspire:Logto:Client:AppSecret"] = "test-secret",
+ });
+
+ builder.AddLogtoOIDC();
+
+ var host = builder.Build();
+ Assert.NotNull(host);
+ }
+
+ [Fact]
+ public void AddLogtoSDKClient_UsesEndpointFromConnectionString_WhenConfigDoesNotContainEndpoint()
+ {
+ var builder = Host.CreateApplicationBuilder();
+
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["Aspire:Logto:Client:AppId"] = "test-app-id",
+ ["Aspire:Logto:Client:AppSecret"] = "test-secret",
+ ["ConnectionStrings:Logto"] = "Endpoint=https://logto-from-cs.example.com"
+ });
+
+ builder.AddLogtoOIDC(connectionName: "Logto");
+
+ var host = builder.Build();
+ Assert.NotNull(host);
+ }
+
+ [Fact]
+ public void AddLogtoSDKClient_ThrowsInvalidOperation_WhenConfigureSettingsClearsEndpoint()
+ {
+ var builder = Host.CreateApplicationBuilder();
+
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["Aspire:Logto:Client:Endpoint"] = "https://logto-config.example.com",
+ ["Aspire:Logto:Client:AppId"] = "test-app-id",
+ ["Aspire:Logto:Client:AppSecret"] = "test-secret",
+ });
+
+ var ex = Assert.Throws(() =>
+ builder.AddLogtoOIDC(logtoOptions: opt =>
+ {
+ opt.Endpoint = " ";
+ }));
+
+ Assert.Equal("Logto Endpoint must be configured.", ex.Message);
+ }
+
+ [Fact]
+ public void AddLogtoSDKClient_ThrowsInvalidOperation_WhenAppIdIsMissingAfterConfigureSettings()
+ {
+ var builder = Host.CreateApplicationBuilder();
+
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["Aspire:Logto:Client:Endpoint"] = "https://logto-config.example.com",
+ ["Aspire:Logto:Client:AppSecret"] = "test-secret",
+ });
+
+ var ex = Assert.Throws(() =>
+ builder.AddLogtoOIDC(logtoOptions: _ =>
+ {
+ //empty
+ }));
+
+ Assert.Equal("Logto AppId must be configured.", ex.Message);
+ }
+}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoConnectionStringHelperTests.cs b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoConnectionStringHelperTests.cs
new file mode 100644
index 000000000..2f3fedd5f
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoConnectionStringHelperTests.cs
@@ -0,0 +1,58 @@
+namespace CommunityToolkit.Aspire.Logto.Client.Tests;
+
+public class LogtoConnectionStringHelperTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void GetEndpointFromConnectionString_ReturnsNull_WhenConnectionStringIsNullOrWhiteSpace(string? connectionString)
+ {
+ var result = LogtoConnectionStringHelper.GetEndpointFromConnectionString(connectionString);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetEndpointFromConnectionString_ReturnsSameString_WhenItIsValidUri()
+ {
+ var connectionString = "https://logto.example.com/";
+
+ var result = LogtoConnectionStringHelper.GetEndpointFromConnectionString(connectionString);
+
+ Assert.Equal(connectionString, result);
+ }
+
+ [Fact]
+ public void GetEndpointFromConnectionString_ReturnsEndpoint_WhenEndpointKeyExistsInConnectionString()
+ {
+ var connectionString =
+ "Endpoint=https://logto.example.com;SomeOtherKey=SomeValue";
+
+ var result = LogtoConnectionStringHelper.GetEndpointFromConnectionString(connectionString);
+
+ Assert.Equal("https://logto.example.com/", result);
+ }
+
+ [Fact]
+ public void GetEndpointFromConnectionString_ReturnsNull_WhenEndpointIsNotValidUri()
+ {
+ var connectionString =
+ "Endpoint=not-a-valid-uri;SomeOtherKey=SomeValue";
+
+ var result = LogtoConnectionStringHelper.GetEndpointFromConnectionString(connectionString);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetEndpointFromConnectionString_ReturnsNull_WhenEndpointKeyMissing()
+ {
+ var connectionString =
+ "Server=localhost;User Id=sa;Password=123;";
+
+ var result = LogtoConnectionStringHelper.GetEndpointFromConnectionString(connectionString);
+
+ Assert.Null(result);
+ }
+}
\ No newline at end of file