diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx
index a904504fa..b35db5cd1 100644
--- a/CommunityToolkit.Aspire.slnx
+++ b/CommunityToolkit.Aspire.slnx
@@ -81,6 +81,12 @@
+
+
+
+
+
+
@@ -219,6 +225,7 @@
+
@@ -246,6 +253,7 @@
+
@@ -283,6 +291,7 @@
+
@@ -309,6 +318,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8c2d31084..5448274b5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -22,7 +22,9 @@
+
+
@@ -34,6 +36,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..269f7b75d
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Hosting.Logto.AppHost/AppHost.cs
@@ -0,0 +1,22 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var postgres = builder.AddPostgres("postgres")
+ .WithDataVolume();
+
+var cache = builder.AddRedis("redis")
+ .WithDataVolume();
+
+var logto = builder.AddLogto("logto", postgres)
+ .WithRedis(cache)
+ .WithDatabaseSeeding();
+
+
+var clientOIDC = builder.AddProject("clientOIDC")
+ .WithReference(logto)
+ .WaitFor(logto);
+var clientJWT = builder.AddProject("clientJWT")
+ .WithReference(logto)
+ .WaitFor(logto);
+
+
+builder.Build().Run();
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..709cff8c1
--- /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..fa63aff4e
--- /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}}/
+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..cfdd35d30
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Program.cs
@@ -0,0 +1,34 @@
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Hosting;
+
+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 = !builder.Environment.IsDevelopment();
+ });
+
+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();
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..69083959b
--- /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}}/
+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..c4b205c59
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/Program.cs
@@ -0,0 +1,75 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Hosting;
+using System.Security.Claims;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.AddServiceDefaults();
+
+builder.AddLogtoOIDC("logto", logtoOptions: config =>
+{
+ config.AppId = builder.Configuration["Logto:AppId"] ?? string.Empty;
+ config.AppSecret = builder.Configuration["Logto:AppSecret"] ?? string.Empty;
+}, oidcOptions: opt =>
+{
+ opt.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
+});
+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();
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..9c9e8aedb
--- /dev/null
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientOIDC/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Logto": {
+ "AppId": "your-logto-app-id",
+ "AppSecret": "your-logto-app-secret"
+ },
+ "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..610f6acd8
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoBuilderExtensions.cs
@@ -0,0 +1,282 @@
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.DependencyInjection;
+
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+namespace Aspire.Hosting;
+
+///
+/// Provides extension methods for configuring and managing Logto resources
+/// within the Aspire hosting application framework.
+///
+public static class LogtoBuilderExtensions
+{
+ ///
+ /// Adds a Logto 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 resource will be added.
+ /// The name of the Logto resource.
+ /// The resource builder for the PostgreSQL server that the Logto will connect to.
+ /// The PostgreSQL database name Logto should use.
+ /// The host port to be configured for the primary endpoint. If , Aspire will assign a random host port.
+ /// The host port to be configured for the administrative endpoint. If , Aspire will assign a random host port.
+ /// The resource builder configured for the added Logto resource.
+ [AspireExport]
+ public static IResourceBuilder AddLogto(
+ this IDistributedApplicationBuilder builder,
+ string name,
+ IResourceBuilder postgres,
+ string databaseName = "logto_db",
+ int? port = null,
+ int? adminPort = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentNullException.ThrowIfNull(postgres);
+ ArgumentException.ThrowIfNullOrEmpty(databaseName);
+
+
+ var resource = new LogtoResource(name);
+ var builderWithResource = builder
+ .AddResource(resource)
+ .WithImage(LogtoContainerImageTags.Image, LogtoContainerImageTags.Tag)
+ .WithImageRegistry(LogtoContainerImageTags.Registry);
+
+ builderWithResource.WithResourcePort(port, adminPort);
+ builderWithResource.WithDatabase(postgres, databaseName);
+ SetHealthCheck(builder, builderWithResource, name);
+
+ return builderWithResource;
+ }
+
+ ///
+ /// Enables Node.js deprecation tracing for the Logto 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 resource that will be configured for stack trace logging.
+ /// The resource builder for the configured Logto resource.
+ [AspireExport]
+ public static IResourceBuilder WithDeprecationTracing(this IResourceBuilder builderWithResource)
+ {
+ ArgumentNullException.ThrowIfNull(builderWithResource);
+
+ return builderWithResource.WithEnvironment("NODE_OPTIONS", "--trace-deprecation");
+ }
+
+ private static void SetHealthCheck(IDistributedApplicationBuilder builder,
+ IResourceBuilder builderWithResource, string name)
+ {
+ var endpoint = builderWithResource.Resource.GetEndpoint(LogtoResource.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);
+ }
+
+ ///
+ /// Configures the Logto resource to use the specified Node.js environment value
+ /// by setting the corresponding environment variable.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The value of the Node.js environment variable to set, typically "development", "production", or "test".
+ /// The resource builder for the configured Logto resource.
+ [AspireExport]
+ public static IResourceBuilder WithNodeEnv(this IResourceBuilder builder, string env)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(env);
+
+ return builder.WithEnvironment("NODE_ENV", env);
+ }
+
+ ///
+ /// Configures the Logto resource with a data volume, allowing persistent storage.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The optional name of the data volume. If not provided, a default name is generated.
+ /// The resource builder configured with the specified data volume.
+ [AspireExport]
+ public static IResourceBuilder WithDataVolume(this IResourceBuilder builder, string? name = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), "/data");
+ }
+
+ ///
+ /// Configures HTTP endpoints for the given Logto resource builder with specified port settings.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The host port to be configured for the primary endpoint. If , Aspire will assign a random host port.
+ /// The host port to be configured for the administrative endpoint. If , Aspire will assign a random host port.
+ /// The updated resource builder with the configured HTTP endpoints.
+ [AspireExport]
+ public static IResourceBuilder WithResourcePort(
+ this IResourceBuilder builder,
+ int? port = null,
+ int? adminPort = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithHttpEndpoint(
+ port: port,
+ targetPort: LogtoResource.DefaultHttpPort,
+ name: LogtoResource.PrimaryEndpointName)
+ .WithHttpEndpoint(
+ port: adminPort,
+ targetPort: LogtoResource.DefaultHttpAdminPort,
+ name: LogtoResource.AdminEndpointName);
+ }
+
+ ///
+ /// Configures the specified Logto resource to include an administrative endpoint with the given URL.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The URL of the administrative endpoint to be used for the Logto resource.
+ /// The resource builder for the configured Logto resource.
+ /// https://admin.domain.com
+ [AspireExport]
+ public static IResourceBuilder WithAdminEndpoint(this IResourceBuilder builder, string url)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(url);
+
+ return builder.WithEnvironment("ADMIN_ENDPOINT", url);
+ }
+
+ ///
+ /// Configures the Logto resource to disable the Admin Console port.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// A value indicating whether to disable the Admin Console port.
+ /// The resource builder for the configured Logto resource.
+ [AspireExport]
+ public static IResourceBuilder WithDisableAdminConsole(this IResourceBuilder builder, bool disable)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithEnvironment("ADMIN_DISABLE_LOCALHOST", disable.ToString());
+ }
+
+ ///
+ /// Configures the Logto resource to enable or disable the trust proxy header behavior.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// A value indicating whether to trust the proxy header.
+ /// The resource builder for the configured Logto resource.
+ [AspireExport]
+ public static IResourceBuilder WithTrustProxyHeader(this IResourceBuilder builder, bool trustProxyHeader)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithEnvironment("TRUST_PROXY_HEADER", trustProxyHeader.ToString());
+ }
+
+ ///
+ /// Specifies whether the username is case-sensitive.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// A value indicating whether usernames should be treated as case-sensitive.
+ /// The updated resource builder with the configured case-sensitivity setting.
+ [AspireExport]
+ public static IResourceBuilder WithSensitiveUsername(this IResourceBuilder builder, bool sensitiveUsername)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder.WithEnvironment("CASE_SENSITIVE_USERNAME", sensitiveUsername.ToString());
+ }
+
+ ///
+ /// Configures the Logto resource to use a secret vault with the specified key encryption key (KEK).
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The base64-encoded key encryption key (KEK) for the secret vault.
+ /// The resource builder for the configured Logto resource.
+ [AspireExport]
+ public static IResourceBuilder WithSecretVault(this IResourceBuilder builder, string secretVaultKek)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(secretVaultKek);
+
+ return builder.WithEnvironment("SECRET_VAULT_KEK", secretVaultKek);
+ }
+
+ ///
+ /// Configures the Logto resource to use a data bind mount with the specified source directory.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The host directory to be mounted as the data volume.
+ /// The resource builder for the configured Logto resource.
+ [AspireExport]
+ public static IResourceBuilder WithDataBindMount(this IResourceBuilder builder, string source)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(source);
+
+ return builder.WithBindMount(source, "/data");
+ }
+
+ ///
+ /// Configures the Logto resource to use a specified Redis resource for caching or other functionality.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The resource builder for the Redis resource to be used by the Logto resource.
+ /// The resource builder configured with the specified Redis resource.
+ [AspireExport]
+ public static IResourceBuilder WithRedis(this IResourceBuilder builder, IResourceBuilder redis)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(redis);
+
+ return builder.WithEnvironment("REDIS_URL", redis.Resource.UriExpression)
+ .WaitFor(redis);
+ }
+
+ ///
+ /// Configures the Logto resource to connect to the specified PostgreSQL database.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The resource builder for the PostgreSQL server to connect to.
+ /// The PostgreSQL database name Logto should use.
+ /// The resource builder for the configured Logto resource.
+ [AspireExport]
+ public static IResourceBuilder WithDatabase(
+ this IResourceBuilder builder,
+ IResourceBuilder postgres,
+ string databaseName = "logto_db")
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(postgres);
+ ArgumentException.ThrowIfNullOrEmpty(databaseName);
+
+ var dbUrlBuilder = new ReferenceExpressionBuilder();
+ dbUrlBuilder.Append($"{postgres.Resource.UriExpression}/{databaseName}");
+ var dbUrl = dbUrlBuilder.Build();
+
+ return builder.WithEnvironment("DB_URL", dbUrl)
+ .WaitFor(postgres);
+ }
+
+ ///
+ /// Starts Logto by running the database seed command before the application process.
+ ///
+ /// The resource builder for the Logto resource to configure.
+ /// The resource builder for the configured Logto resource.
+ [AspireExport]
+ public static IResourceBuilder WithDatabaseSeeding(this IResourceBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ return builder
+ .WithEntrypoint("sh")
+ .WithArgs("-c", "npm run cli db seed -- --swe && npm start");
+ }
+}
+
+#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoResource.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoResource.cs
new file mode 100644
index 000000000..64c6c4e83
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoResource.cs
@@ -0,0 +1,76 @@
+#pragma warning disable ASPIREATS001 // AspireExport is experimental
+
+namespace Aspire.Hosting.ApplicationModel;
+
+///
+/// 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.
+///
+[AspireExport(ExposeProperties = true)]
+public sealed class LogtoResource(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;
+
+ private EndpointReference? _primaryEndpointReference;
+
+ /// 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 => _primaryEndpointReference ??= 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);
+
+
+ /// 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 =>
+ ReferenceExpression.Create(
+ $"Endpoint={PrimaryEndpoint.Property(EndpointProperty.Scheme)}://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}");
+
+ ///
+ /// Gets the connection URI expression for the Logto container resource.
+ ///
+ public ReferenceExpression UriExpression =>
+ ReferenceExpression.Create(
+ $"{PrimaryEndpoint.Property(EndpointProperty.Scheme)}://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}");
+
+ IEnumerable> IResourceWithConnectionString.GetConnectionProperties()
+ {
+ yield return new("Host", ReferenceExpression.Create($"{Host}"));
+ yield return new("Port", ReferenceExpression.Create($"{Port}"));
+ yield return new("Uri", UriExpression);
+ }
+}
+
+#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoTags.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoTags.cs
new file mode 100644
index 000000000..41d572e9d
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoTags.cs
@@ -0,0 +1,16 @@
+namespace Aspire.Hosting;
+
+///
+/// Represents a collection of constants for container tags related to the Logto application.
+///
+internal sealed class LogtoContainerImageTags
+{
+ /// docker.io
+ public const string Registry = "docker.io";
+
+ /// svhd/logto
+ public const string Image = "svhd/logto";
+
+ /// 1.39
+ public const string Tag = "1.39";
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/README.md b/src/CommunityToolkit.Aspire.Hosting.Logto/README.md
new file mode 100644
index 000000000..117ed40be
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/README.md
@@ -0,0 +1,67 @@
+# 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 `AddLogto(...)`.
+- 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;
+var postgres = builder.AddPostgres("postgres");
+
+// Basic setup connecting to Postgres
+var logto = builder
+ .AddLogto("logto", postgres)
+ .WithDatabaseSeeding();
+
+// Advanced setup with Redis and specific configurations
+var redis = builder.AddRedis("redis");
+
+var logtoSecure = builder
+ .AddLogto("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 `Aspire.Hosting` namespace.
+* Call `.WithDatabaseSeeding()` to run the database seeding command
+ `npm run cli db seed -- --swe && npm start` on startup.
+* Container ports are **3001** (HTTP) and **3002** (Admin). Host ports are random by default unless explicitly configured.
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/api/CommunityToolkit.Aspire.Hosting.Logto.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/api/CommunityToolkit.Aspire.Hosting.Logto.cs
new file mode 100644
index 000000000..e803b050e
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/api/CommunityToolkit.Aspire.Hosting.Logto.cs
@@ -0,0 +1,76 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+namespace Aspire.Hosting
+{
+ public static partial class LogtoBuilderExtensions
+ {
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder AddLogto(this IDistributedApplicationBuilder builder, string name, ApplicationModel.IResourceBuilder postgres, string databaseName = "logto_db", int? port = null, int? adminPort = null) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithAdminEndpoint(this ApplicationModel.IResourceBuilder builder, string url) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithDatabase(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder postgres, string databaseName = "logto_db") { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithDatabaseSeeding(this ApplicationModel.IResourceBuilder builder) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithDataBindMount(this ApplicationModel.IResourceBuilder builder, string source) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithDataVolume(this ApplicationModel.IResourceBuilder builder, string? name = null) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithDeprecationTracing(this ApplicationModel.IResourceBuilder builderWithResource) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithDisableAdminConsole(this ApplicationModel.IResourceBuilder builder, bool disable) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithNodeEnv(this ApplicationModel.IResourceBuilder builder, string env) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithRedis(this ApplicationModel.IResourceBuilder builder, ApplicationModel.IResourceBuilder redis) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithResourcePort(this ApplicationModel.IResourceBuilder builder, int? port = null, int? adminPort = null) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithSecretVault(this ApplicationModel.IResourceBuilder builder, string secretVaultKek) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithSensitiveUsername(this ApplicationModel.IResourceBuilder builder, bool sensitiveUsername) { throw null; }
+
+ [AspireExport]
+ public static ApplicationModel.IResourceBuilder WithTrustProxyHeader(this ApplicationModel.IResourceBuilder builder, bool trustProxyHeader) { throw null; }
+ }
+}
+
+namespace Aspire.Hosting.ApplicationModel
+{
+ [AspireExport(ExposeProperties = true)]
+ public sealed partial class LogtoResource : ContainerResource, IResourceWithConnectionString, IResource, IExpressionValue, IValueProvider, IManifestExpressionProvider, IValueWithReferences
+ {
+ public LogtoResource(string name) : base(default!, default) { }
+
+ public ReferenceExpression ConnectionStringExpression { get { throw null; } }
+
+ public EndpointReferenceExpression Host { get { throw null; } }
+
+ public EndpointReferenceExpression Port { get { throw null; } }
+
+ public EndpointReference PrimaryEndpoint { get { throw null; } }
+
+ public ReferenceExpression UriExpression { get { throw null; } }
+
+ System.Collections.Generic.IEnumerable> IResourceWithConnectionString.GetConnectionProperties() { throw null; }
+ }
+}
\ No newline at end of file
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..04f9a8bcb
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Logto.Client/LogtoClientBuilder.cs
@@ -0,0 +1,230 @@
+using Logto.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using CommunityToolkit.Aspire.Logto.Client;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Microsoft.Extensions.Hosting;
+
+///
+/// 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 = GetValidatedOptions(builder.Configuration, configurationSectionName, connectionName, logtoOptions);
+
+ builder.Services.AddLogtoAuthentication(authenticationScheme, cookieScheme, opt =>
+ {
+ opt.Endpoint = options.Endpoint;
+ opt.AppId = options.AppId;
+ opt.AppSecret = options.AppSecret;
+ });
+ 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)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(serviceName);
+ ArgumentException.ThrowIfNullOrEmpty(appIdentification);
+
+ 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 appIdentification 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)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(serviceName);
+ ArgumentNullException.ThrowIfNull(appIdentification);
+
+ var audiences = appIdentification as string[] ?? appIdentification.ToArray();
+ if (audiences.Length == 0 || audiences.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new ArgumentException("At least one non-empty application identifier must be provided.", nameof(appIdentification));
+ }
+
+ 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 = audiences
+ };
+ configureOptions?.Invoke(jwt);
+ });
+ builder.AddJwtBearer(authenticationScheme);
+ return builder;
+ }
+
+ private static LogtoOptions GetValidatedOptions(
+ IConfiguration configuration,
+ string? configurationSectionName,
+ string? connectionName,
+ Action? configureOptions = null)
+ {
+ var options = GetConfiguredOptions(configuration, configurationSectionName, connectionName);
+ configureOptions?.Invoke(options);
+
+ if (string.IsNullOrWhiteSpace(options.Endpoint))
+ {
+ throw new InvalidOperationException("Logto Endpoint must be configured.");
+ }
+
+ if (string.IsNullOrWhiteSpace(options.AppId))
+ {
+ throw new InvalidOperationException("Logto AppId must be configured.");
+ }
+
+ return options;
+ }
+
+ private static LogtoOptions GetConfiguredOptions(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;
+ }
+ }
+
+ return options;
+ }
+
+ private static LogtoOptions GetEndpoint(IConfiguration configuration, string? configurationSectionName,
+ string? connectionName)
+ {
+ var options = GetConfiguredOptions(configuration, configurationSectionName, connectionName);
+
+ if (string.IsNullOrWhiteSpace(options.Endpoint))
+ {
+ throw new InvalidOperationException(
+ $"Logto Endpoint must be configured in configuration section '{configurationSectionName ?? DefaultConfigSectionName}' or in connection string '{connectionName}'.");
+ }
+
+ return options;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs b/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs
new file mode 100644
index 000000000..78655a761
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs
@@ -0,0 +1,55 @@
+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 static 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();
+ }
+
+ DbConnectionStringBuilder builder;
+ try
+ {
+ builder = new DbConnectionStringBuilder { ConnectionString = connectionString };
+ }
+ catch (ArgumentException)
+ {
+ return null;
+ }
+
+ if (builder.TryGetValue(ConnectionStringEndpointKey, out var endpointObj) &&
+ endpointObj is string endpoint &&
+ Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri))
+ {
+ return endpointUri.ToString();
+ }
+
+ return null;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Logto.Client/api/CommunityToolkit.Aspire.Logto.Client.cs b/src/CommunityToolkit.Aspire.Logto.Client/api/CommunityToolkit.Aspire.Logto.Client.cs
new file mode 100644
index 000000000..33ea10d4f
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Logto.Client/api/CommunityToolkit.Aspire.Logto.Client.cs
@@ -0,0 +1,27 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+namespace CommunityToolkit.Aspire.Logto.Client
+{
+ public static partial class LogtoConnectionStringHelper
+ {
+ public static string? GetEndpointFromConnectionString(string? connectionString) { throw null; }
+ }
+}
+
+namespace Microsoft.Extensions.Hosting
+{
+ public static partial class LogtoClientBuilder
+ {
+ public static AspNetCore.Authentication.AuthenticationBuilder AddLogtoJwtBearer(this AspNetCore.Authentication.AuthenticationBuilder builder, string serviceName, System.Collections.Generic.IEnumerable appIdentification, string authenticationScheme = "Bearer", string? configurationSectionName = "Aspire:Logto:Client", System.Action? configureOptions = null) { throw null; }
+
+ public static AspNetCore.Authentication.AuthenticationBuilder AddLogtoJwtBearer(this AspNetCore.Authentication.AuthenticationBuilder builder, string serviceName, string appIdentification, string authenticationScheme = "Bearer", string? configurationSectionName = "Aspire:Logto:Client", System.Action? configureOptions = null) { throw null; }
+
+ public static DependencyInjection.IServiceCollection AddLogtoOIDC(this IHostApplicationBuilder builder, string? connectionName = null, string? configurationSectionName = "Aspire:Logto:Client", string authenticationScheme = "Logto", string cookieScheme = "Logto.Cookie", System.Action? logtoOptions = null, System.Action? oidcOptions = null) { throw 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..834be2b0a
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/AppHostTest.cs
@@ -0,0 +1,25 @@
+using Aspire.Components.Common.Tests;
+using CommunityToolkit.Aspire.Testing;
+
+namespace CommunityToolkit.Aspire.Hosting.Logto.Tests;
+
+[RequiresDocker]
+public class AppHostTest(
+ AspireIntegrationTestFixture fixture
+) : IClassFixture>
+{
+ [Fact]
+ public async Task LogtoResourceStartsAndRespondsOk()
+ {
+ const string resourceName = "logto";
+
+ await fixture.ResourceNotificationService
+ .WaitForResourceHealthyAsync(resourceName)
+ .WaitAsync(TimeSpan.FromMinutes(5));
+
+ var httpClient = fixture.CreateHttpClient(resourceName);
+ var response = await httpClient.GetAsync("/");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+}
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..e928d2eba
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/CommunityToolkit.Aspire.Hosting.Logto.Tests.csproj
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
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..4b3e185da
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/ResourceCreationTests.cs
@@ -0,0 +1,81 @@
+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.AddLogto("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.AddLogto("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);
+ }
+
+ [Fact]
+ public void LogtoResourceDoesNotOverrideEntrypointByDefault()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var postgres = builder.AddPostgres("postgres");
+
+ builder.AddLogto("logto", postgres);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ Assert.False(resource.TryGetAnnotationsOfType(out _));
+ }
+
+ [Fact]
+ public void LogtoResourceCanUseSeededStartup()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var postgres = builder.AddPostgres("postgres");
+
+ builder.AddLogto("logto", postgres)
+ .WithDatabaseSeeding();
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ Assert.True(resource.TryGetAnnotationsOfType(out _));
+ }
+}
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..56b38400e
--- /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.Hosting;
+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 AddLogtoOIDC_UsesConnectionStringEndpoint_WhenSectionEndpointMissing()
+ {
+ // Arrange
+ 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
+ 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);
+ }
+}
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..6b3f37568
--- /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 AddLogtoOIDC_ThrowsArgumentNull_WhenBuilderIsNull()
+ {
+ IHostApplicationBuilder? builder = null;
+
+ var ex = Assert.Throws(() =>
+ builder!.AddLogtoOIDC());
+
+ Assert.Equal("builder", ex.ParamName);
+ }
+
+ [Fact]
+ public void AddLogtoOIDC_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 AddLogtoOIDC_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 AddLogtoOIDC_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 AddLogtoOIDC_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 AddLogtoOIDC_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);
+ }
+}
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..d0c53886f
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoConnectionStringHelperTests.cs
@@ -0,0 +1,66 @@
+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);
+ }
+
+ [Fact]
+ public void GetEndpointFromConnectionString_ReturnsNull_WhenConnectionStringIsMalformed()
+ {
+ var result = LogtoConnectionStringHelper.GetEndpointFromConnectionString("Endpoint=https://logto.example.com;=");
+
+ Assert.Null(result);
+ }
+}