diff --git a/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Program.cs b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Program.cs
index cfdd35d30..01cd09a6e 100644
--- a/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Program.cs
+++ b/examples/logto/CommunityToolkit.Aspire.Logto.ClientJWT/Program.cs
@@ -4,7 +4,7 @@
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
-const string apiAudience = "http://localhost:5072/";
+const string apiAudience = "http://127.0.0.1:9234/";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddLogtoJwtBearer("logto", appIdentification: apiAudience,
configureOptions: opt =>
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/CommunityToolkit.Aspire.Hosting.Logto.csproj b/src/CommunityToolkit.Aspire.Hosting.Logto/CommunityToolkit.Aspire.Hosting.Logto.csproj
index 8c8ce9383..5a986ee48 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Logto/CommunityToolkit.Aspire.Hosting.Logto.csproj
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/CommunityToolkit.Aspire.Hosting.Logto.csproj
@@ -4,6 +4,7 @@
.NET Aspire hosting extensions for Logto (includes PostgreSQL and Redis integration).
logto redis postgres hosting extensions
+ true
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoBuilderExtensions.cs
index 610f6acd8..1ef030a93 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoBuilderExtensions.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoBuilderExtensions.cs
@@ -1,5 +1,6 @@
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
+using System.ComponentModel;
#pragma warning disable ASPIREATS001 // AspireExport is experimental
@@ -25,7 +26,7 @@ public static class LogtoBuilderExtensions
[AspireExport]
public static IResourceBuilder AddLogto(
this IDistributedApplicationBuilder builder,
- string name,
+ [ResourceName] string name,
IResourceBuilder postgres,
string databaseName = "logto_db",
int? port = null,
@@ -44,12 +45,52 @@ public static IResourceBuilder AddLogto(
.WithImageRegistry(LogtoContainerImageTags.Registry);
builderWithResource.WithResourcePort(port, adminPort);
+ builderWithResource.WithEnvironment(context =>
+ {
+ // Logto uses these public URLs for browser redirects and Admin Console API calls.
+ // They are only concrete after Aspire allocates the run-mode host ports.
+ if (resource.PrimaryEndpoint.IsAllocated)
+ {
+ context.EnvironmentVariables.TryAdd("ENDPOINT", resource.PrimaryEndpoint.Url);
+ }
+
+ if (resource.AdminEndpointUrl is not null)
+ {
+ context.EnvironmentVariables.TryAdd("ADMIN_ENDPOINT", resource.AdminEndpointUrl);
+ }
+ else
+ {
+ var adminEndpoint = resource.GetEndpoint(LogtoResource.AdminEndpointName);
+ if (adminEndpoint.IsAllocated)
+ {
+ context.EnvironmentVariables.TryAdd("ADMIN_ENDPOINT", GetCorsSafeLocalAdminUrl(adminEndpoint.Url));
+ }
+ }
+ });
+ if (builder.ExecutionContext.IsRunMode)
+ {
+ builderWithResource.WithUrlForEndpoint(LogtoResource.AdminEndpointName, url =>
+ url.Url = resource.AdminEndpointUrl ?? GetCorsSafeLocalAdminUrl(url.Url));
+ }
builderWithResource.WithDatabase(postgres, databaseName);
SetHealthCheck(builder, builderWithResource, name);
return builderWithResource;
}
+ private static string GetCorsSafeLocalAdminUrl(string url)
+ {
+ var uri = new Uri(url);
+
+ if (!uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ return url;
+ }
+
+ var builder = new UriBuilder(uri) { Host = "127.0.0.1" };
+ return builder.Uri.GetLeftPart(UriPartial.Authority);
+ }
+
///
/// Enables Node.js deprecation tracing for the Logto by setting the
/// NODE_OPTIONS environment variable to '--trace-deprecation'.
@@ -131,7 +172,8 @@ public static IResourceBuilder WithResourcePort(
.WithHttpEndpoint(
port: adminPort,
targetPort: LogtoResource.DefaultHttpAdminPort,
- name: LogtoResource.AdminEndpointName);
+ name: LogtoResource.AdminEndpointName)
+ .WithEndpoint(LogtoResource.AdminEndpointName, endpoint => endpoint.ExcludeReferenceEndpoint = true);
}
///
@@ -147,7 +189,9 @@ public static IResourceBuilder WithAdminEndpoint(this IResourceBu
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(url);
- return builder.WithEnvironment("ADMIN_ENDPOINT", url);
+ builder.Resource.AdminEndpointUrl = url;
+
+ return builder;
}
///
@@ -184,7 +228,9 @@ public static IResourceBuilder WithTrustProxyHeader(this IResourc
/// 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]
+ [AspireExportIgnore(Reason = "CASE_SENSITIVE_USERNAME was removed in Logto 1.41 and is retained only for source compatibility.")]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ [Obsolete("CASE_SENSITIVE_USERNAME is deprecated in Logto 1.41. Configure username case sensitivity per tenant in the Logto Console instead.")]
public static IResourceBuilder WithSensitiveUsername(this IResourceBuilder builder, bool sensitiveUsername)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -259,23 +305,58 @@ public static IResourceBuilder WithDatabase(
dbUrlBuilder.Append($"{postgres.Resource.UriExpression}/{databaseName}");
var dbUrl = dbUrlBuilder.Build();
+ builder.Resource.DatabaseUrl = dbUrl;
+ builder.Resource.PostgresResource = postgres.Resource;
+
return builder.WithEnvironment("DB_URL", dbUrl)
.WaitFor(postgres);
}
///
- /// Starts Logto by running the database seed command before the application process.
+ /// Seeds and upgrades the Logto database in a one-shot setup container before Logto starts.
///
/// The resource builder for the Logto resource to configure.
+ ///
+ /// Disables the Have I Been Pwned check for the initial admin password. Use this only in air-gapped environments.
+ ///
/// The resource builder for the configured Logto resource.
[AspireExport]
- public static IResourceBuilder WithDatabaseSeeding(this IResourceBuilder builder)
+ public static IResourceBuilder WithDatabaseSeeding(
+ this IResourceBuilder builder,
+ bool disableAdminPwnedPasswordCheck = false)
{
ArgumentNullException.ThrowIfNull(builder);
- return builder
+ if (builder.ApplicationBuilder.ExecutionContext.IsPublishMode)
+ {
+ return builder;
+ }
+
+ var databaseUrl = builder.Resource.DatabaseUrl
+ ?? throw new InvalidOperationException("Configure the Logto database before enabling database seeding.");
+ var postgres = builder.Resource.PostgresResource
+ ?? throw new InvalidOperationException("Configure the Logto PostgreSQL resource before enabling database seeding.");
+ var setupName = $"{builder.Resource.Name}-database-setup";
+ if (builder.ApplicationBuilder.TryCreateResourceBuilder(setupName, out _))
+ {
+ return builder;
+ }
+
+ var seedArguments = disableAdminPwnedPasswordCheck ? "--swe --dapc" : "--swe";
+
+ var setup = builder.ApplicationBuilder
+ .AddResource(new LogtoDatabaseSetupResource(setupName))
+ .WithImage(LogtoContainerImageTags.Image, LogtoContainerImageTags.Tag)
+ .WithImageRegistry(LogtoContainerImageTags.Registry)
.WithEntrypoint("sh")
- .WithArgs("-c", "npm run cli db seed -- --swe && npm start");
+ .WithArgs("-c", $"npm run cli db seed -- {seedArguments} && npm run alteration deploy latest")
+ .WithEnvironment("DB_URL", databaseUrl)
+ .WithEnvironment("CI", "true")
+ .WithParentRelationship(builder.Resource)
+ .ExcludeFromManifest()
+ .WaitFor(builder.ApplicationBuilder.CreateResourceBuilder(postgres));
+
+ return builder.WaitForCompletion(setup);
}
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoResource.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoResource.cs
index 01cae638d..d7504de26 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoResource.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoResource.cs
@@ -21,6 +21,12 @@ public sealed class LogtoResource(string name)
private EndpointReference? _primaryEndpointReference;
+ internal ReferenceExpression? DatabaseUrl { get; set; }
+
+ internal PostgresServerResource? PostgresResource { get; set; }
+
+ internal string? AdminEndpointUrl { get; set; }
+
/// 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.
@@ -72,4 +78,6 @@ IEnumerable> IResourceWithConnectionSt
}
}
+internal sealed class LogtoDatabaseSetupResource(string name) : ContainerResource(name);
+
#pragma warning restore ASPIREATS001
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoTags.cs b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoTags.cs
index 41d572e9d..c9662a886 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoTags.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/LogtoTags.cs
@@ -11,6 +11,6 @@ internal sealed class LogtoContainerImageTags
/// svhd/logto
public const string Image = "svhd/logto";
- /// 1.39
- public const string Tag = "1.39";
+ /// 1.41
+ public const string Tag = "1.41";
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Logto/README.md b/src/CommunityToolkit.Aspire.Hosting.Logto/README.md
index 117ed40be..879ba1de9 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Logto/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.Logto/README.md
@@ -1,67 +1,82 @@
-# Logto Hosting Extensions for .NET Aspire
+# Logto hosting integration
-## Overview
+Use this integration to model, configure, and orchestrate a [Logto](https://logto.io/) identity service in an Aspire distributed application. It connects Logto to PostgreSQL, optionally connects Redis, exposes the public and Admin Console endpoints, and provides a one-shot database setup command for local runs.
-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.
-
----
+## Getting started
-## Features
+Install the hosting integration in the AppHost directory:
-- 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`.
-
----
+```console
+aspire add CommunityToolkit.Aspire.Hosting.Logto
+```
-## Usage (AppHost)
+## Usage example
```csharp
-using Aspire.Hosting;
-using Aspire.Hosting.Postgres;
+var builder = DistributedApplication.CreateBuilder(args);
+
var postgres = builder.AddPostgres("postgres");
+var redis = builder.AddRedis("redis");
-// Basic setup connecting to Postgres
-var logto = builder
- .AddLogto("logto", postgres)
+var logto = builder.AddLogto("logto", postgres)
+ .WithRedis(redis)
.WithDatabaseSeeding();
-// Advanced setup with Redis and specific configurations
-var redis = builder.AddRedis("redis");
+builder.AddProject("api")
+ .WithReference(logto)
+ .WaitFor(logto);
-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");
+builder.Build().Run();
+```
+
+The same integration is exported to a TypeScript AppHost:
+
+```typescript
+const postgres = await builder.addPostgres("postgres");
+const redis = await builder.addRedis("redis");
+const logto = await builder.addLogto("logto", postgres);
+const api = await builder.addProject("api", "../MyApi/MyApi.csproj");
+
+await logto.withRedis(redis);
+await logto.withDatabaseSeeding();
+await api.withReference(logto);
+await api.waitFor(logto);
```
-Logto will be configured with:
+`AddLogto` configures `DB_URL`, `ENDPOINT`, `ADMIN_ENDPOINT`, the `/api/status` health check, and the dependency on PostgreSQL. `WithRedis` adds `REDIS_URL` and the Redis dependency.
+
+### Database setup
+
+Call `WithDatabaseSeeding()` to seed the database and deploy required schema alterations in a one-shot setup container before Logto starts. The helper is run-only and is excluded from published manifests. Repeated calls do not create duplicate setup resources.
+
+For an air-gapped environment, use `WithDatabaseSeeding(disableAdminPwnedPasswordCheck: true)` to pass Logto's `--dapc` option during initial administrator creation.
+
+### Local Admin Console and CORS
+
+Logto's API and Admin Console use separate endpoints. During local Aspire runs, the integration sets `ENDPOINT` to the allocated API URL and normalizes the Admin Console URL to `127.0.0.1`. This is required by Logto 1.41's production CORS behavior.
+
+Open the exact `admin` URL shown in the Aspire Dashboard. Replacing `127.0.0.1` with `localhost` can cause requests such as `/api/resources` to fail CORS preflight checks and prevent Applications from being created. `WithAdminEndpoint("https://admin.example.com")` updates both Logto's `ADMIN_ENDPOINT` and the Dashboard URL.
+
+When TLS terminates at a reverse proxy, configure `WithTrustProxyHeader(true)` and ensure that the proxy sends `X-Forwarded-Proto`.
+
+## Connection properties
+
+When another resource uses `WithReference(logto)`, Aspire injects the connection string in the standard `ConnectionStrings__{resourceName}` environment variable. The connection string has the form `Endpoint={Uri}`.
+
+| Property | Description | Example |
+| --- | --- | --- |
+| `Host` | Host name of the public Logto endpoint. | `localhost` |
+| `Port` | Allocated port of the public Logto endpoint. | `10611` |
+| `Uri` | Complete public Logto endpoint URL. | `http://localhost:10611` |
+
+The Admin Console endpoint is intentionally excluded from service-reference endpoint expansion; consumers receive the public Logto endpoint only.
-* `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`.
+## Additional documentation
----
+- [Logto documentation](https://docs.logto.io/)
+- [Logto configuration](https://docs.logto.io/logto-oss/using-cli/logto-config)
+- [Aspire service discovery](https://learn.microsoft.com/dotnet/aspire/service-discovery/overview)
-## Notes
+## Feedback & contributing
-* 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.
+Report bugs and request features in the [CommunityToolkit/Aspire issue tracker](https://github.com/CommunityToolkit/Aspire/issues). Contributions are welcome; see the repository's [contributing guide](https://github.com/CommunityToolkit/Aspire/blob/main/CONTRIBUTING.md).
diff --git a/src/CommunityToolkit.Aspire.Logto.Client/LogtoClientBuilder.cs b/src/CommunityToolkit.Aspire.Logto.Client/LogtoClientBuilder.cs
index 04f9a8bcb..cce5cad25 100644
--- a/src/CommunityToolkit.Aspire.Logto.Client/LogtoClientBuilder.cs
+++ b/src/CommunityToolkit.Aspire.Logto.Client/LogtoClientBuilder.cs
@@ -1,8 +1,8 @@
-using Logto.AspNetCore.Authentication;
+using CommunityToolkit.Aspire.Logto.Client;
+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;
@@ -60,6 +60,13 @@ public static IServiceCollection AddLogtoOIDC(this IHostApplicationBuilder build
opt.Endpoint = options.Endpoint;
opt.AppId = options.AppId;
opt.AppSecret = options.AppSecret;
+ opt.Scopes = options.Scopes;
+ opt.Resource = options.Resource;
+ opt.Prompt = options.Prompt;
+ opt.CallbackPath = options.CallbackPath;
+ opt.SignedOutCallbackPath = options.SignedOutCallbackPath;
+ opt.GetClaimsFromUserInfoEndpoint = options.GetClaimsFromUserInfoEndpoint;
+ opt.CookieDomain = options.CookieDomain;
});
builder.Services.Configure(authenticationScheme, opt =>
{
@@ -183,6 +190,8 @@ private static LogtoOptions GetValidatedOptions(
throw new InvalidOperationException("Logto Endpoint must be configured.");
}
+ ValidateEndpoint(options.Endpoint);
+
if (string.IsNullOrWhiteSpace(options.AppId))
{
throw new InvalidOperationException("Logto AppId must be configured.");
@@ -225,6 +234,16 @@ private static LogtoOptions GetEndpoint(IConfiguration configuration, string? co
$"Logto Endpoint must be configured in configuration section '{configurationSectionName ?? DefaultConfigSectionName}' or in connection string '{connectionName}'.");
}
+ ValidateEndpoint(options.Endpoint);
+
return options;
}
-}
+
+ private static void ValidateEndpoint(string endpoint)
+ {
+ if (!LogtoConnectionStringHelper.TryCreateHttpUri(endpoint, out _))
+ {
+ throw new InvalidOperationException("Logto Endpoint must be an absolute HTTP or HTTPS URI.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs b/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs
index 78655a761..4698a8258 100644
--- a/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs
+++ b/src/CommunityToolkit.Aspire.Logto.Client/LogtoConnectionStringHelper.cs
@@ -27,8 +27,8 @@ public static class LogtoConnectionStringHelper
{
return null;
}
-
- if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri))
+
+ if (TryCreateHttpUri(connectionString, out var uri))
{
return uri.ToString();
}
@@ -45,11 +45,24 @@ public static class LogtoConnectionStringHelper
if (builder.TryGetValue(ConnectionStringEndpointKey, out var endpointObj) &&
endpointObj is string endpoint &&
- Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri))
+ TryCreateHttpUri(endpoint, out var endpointUri))
{
return endpointUri.ToString();
}
return null;
}
-}
+
+ internal static bool TryCreateHttpUri(string endpoint, out Uri uri)
+ {
+ if (Uri.TryCreate(endpoint, UriKind.Absolute, out var candidate) &&
+ (candidate.Scheme == Uri.UriSchemeHttp || candidate.Scheme == Uri.UriSchemeHttps))
+ {
+ uri = candidate;
+ return true;
+ }
+
+ uri = null!;
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Logto.Client/README.md b/src/CommunityToolkit.Aspire.Logto.Client/README.md
index 25ad513a7..0f620cd9f 100644
--- a/src/CommunityToolkit.Aspire.Logto.Client/README.md
+++ b/src/CommunityToolkit.Aspire.Logto.Client/README.md
@@ -76,6 +76,8 @@ The Aspire Logto Client integration supports [Microsoft.Extensions.Configuration
}
```
+All properties exposed by `LogtoOptions`, including scopes, resource, prompt, callback paths, user-info claims, and cookie domain, can be configured in this section or through the inline delegate. The endpoint must be an absolute HTTP or HTTPS URI.
+
### Use inline delegates
You can also pass the `Action` delegate to set up some or all the options inline, for example to set the AppId from code:
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/ResourceCreationTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/ResourceCreationTests.cs
index 4b3e185da..7573dafe3 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/ResourceCreationTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Logto.Tests/ResourceCreationTests.cs
@@ -1,4 +1,6 @@
using Aspire.Hosting;
+using Aspire.Hosting.Utils;
+using CommunityToolkit.Aspire.Testing;
namespace CommunityToolkit.Aspire.Hosting.Logto.Tests;
@@ -61,7 +63,20 @@ public void LogtoResourceDoesNotOverrideEntrypointByDefault()
}
[Fact]
- public void LogtoResourceCanUseSeededStartup()
+ public void AdminEndpointIsNotExportedAsAReferenceEndpoint()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var postgres = builder.AddPostgres("postgres");
+
+ var logto = builder.AddLogto("logto", postgres);
+
+ var adminEndpoint = Assert.Single(logto.Resource.Annotations.OfType(),
+ endpoint => endpoint.Name == "admin");
+ Assert.True(adminEndpoint.ExcludeReferenceEndpoint);
+ }
+
+ [Fact]
+ public async Task LogtoResourceUsesOneShotDatabaseSetup()
{
var builder = DistributedApplication.CreateBuilder();
@@ -75,7 +90,66 @@ public void LogtoResourceCanUseSeededStartup()
var appModel = app.Services.GetRequiredService();
var resource = Assert.Single(appModel.Resources.OfType());
+ var setup = Assert.Single(appModel.Resources.OfType(), candidate =>
+ candidate.Name == "logto-database-setup");
+
+ Assert.False(resource.TryGetAnnotationsOfType(out _));
+ Assert.Equal(["-c", "npm run cli db seed -- --swe && npm run alteration deploy latest"], await setup.GetArgumentListAsync());
+ Assert.Contains(ManifestPublishingCallbackAnnotation.Ignore, setup.Annotations);
+ Assert.Contains(resource.Annotations.OfType(), wait =>
+ wait.Resource == setup && wait.WaitType == WaitType.WaitForCompletion);
+ }
+
+ [Fact]
+ public async Task LogtoDatabaseSetupCanDisablePwnedPasswordCheck()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var postgres = builder.AddPostgres("postgres");
+
+ builder.AddLogto("logto", postgres)
+ .WithDatabaseSeeding(disableAdminPwnedPasswordCheck: true);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+ var setup = Assert.Single(appModel.Resources.OfType(), candidate =>
+ candidate.Name == "logto-database-setup");
+
+ Assert.Equal(["-c", "npm run cli db seed -- --swe --dapc && npm run alteration deploy latest"], await setup.GetArgumentListAsync());
+ }
+
+ [Fact]
+ public void LogtoDatabaseSetupIsIdempotent()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var postgres = builder.AddPostgres("postgres");
+
+ var logto = builder.AddLogto("logto", postgres);
+ logto.WithDatabaseSeeding();
+ logto.WithDatabaseSeeding();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ Assert.Single(appModel.Resources.OfType(), candidate =>
+ candidate.Name == "logto-database-setup");
+ Assert.Single(logto.Resource.Annotations.OfType(), wait =>
+ wait.WaitType == WaitType.WaitForCompletion);
+ }
+
+ [Fact]
+ public void LogtoDatabaseSetupIsNotAddedInPublishMode()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
+ var postgres = builder.AddPostgres("postgres");
+
+ var logto = builder.AddLogto("logto", postgres)
+ .WithDatabaseSeeding();
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
- Assert.True(resource.TryGetAnnotationsOfType(out _));
+ Assert.DoesNotContain(appModel.Resources, resource => resource.Name == "logto-database-setup");
+ Assert.DoesNotContain(logto.Resource.Annotations.OfType(), wait =>
+ wait.WaitType == WaitType.WaitForCompletion);
}
}
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
index 70a687ce9..9599d082b 100644
--- 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
@@ -1,4 +1,8 @@
+
+ Exe
+
+
@@ -7,4 +11,4 @@
-
\ 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
index 56b38400e..bba5b97d9 100644
--- a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderIntegrationTests.cs
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderIntegrationTests.cs
@@ -120,4 +120,32 @@ public void AddLogtoClient_ConfigureSettings_CanOverrideOptions()
Assert.StartsWith("https://overridden.example.com", options.Endpoint);
Assert.Equal("overridden-app-id", options.AppId);
}
+
+ [Fact]
+ public void AddLogtoOIDC_PreservesAllLogtoOptions()
+ {
+ var builder = CreateBuilderWithBaseConfig();
+
+ builder.AddLogtoOIDC(logtoOptions: options =>
+ {
+ options.Scopes = ["openid", "email"];
+ options.Resource = "https://api.example.com";
+ options.Prompt = "consent";
+ options.CallbackPath = "/custom-callback";
+ options.SignedOutCallbackPath = "/custom-signout";
+ options.GetClaimsFromUserInfoEndpoint = true;
+ options.CookieDomain = "example.com";
+ });
+
+ using var host = builder.Build();
+ var options = host.Services.GetRequiredService>().Get("Logto");
+
+ Assert.Equal(["openid", "email"], options.Scopes);
+ Assert.Equal("https://api.example.com", options.Resource);
+ Assert.Equal("consent", options.Prompt);
+ Assert.Equal("/custom-callback", options.CallbackPath);
+ Assert.Equal("/custom-signout", options.SignedOutCallbackPath);
+ Assert.True(options.GetClaimsFromUserInfoEndpoint);
+ Assert.Equal("example.com", options.CookieDomain);
+ }
}
diff --git a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderTests.cs b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderTests.cs
index 6b3f37568..0ac72c4ed 100644
--- a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderTests.cs
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoClientBuilderTests.cs
@@ -107,4 +107,21 @@ public void AddLogtoOIDC_ThrowsInvalidOperation_WhenAppIdIsMissingAfterConfigure
Assert.Equal("Logto AppId must be configured.", ex.Message);
}
+
+ [Theory]
+ [InlineData("relative/path")]
+ [InlineData("ftp://logto.example.com")]
+ public void AddLogtoOIDC_ThrowsInvalidOperation_WhenEndpointIsNotHttp(string endpoint)
+ {
+ var builder = Host.CreateApplicationBuilder();
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["Aspire:Logto:Client:Endpoint"] = endpoint,
+ ["Aspire:Logto:Client:AppId"] = "test-app-id"
+ });
+
+ var ex = Assert.Throws(() => builder.AddLogtoOIDC());
+
+ Assert.Equal("Logto Endpoint must be an absolute HTTP or HTTPS URI.", ex.Message);
+ }
}
diff --git a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoConnectionStringHelperTests.cs b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoConnectionStringHelperTests.cs
index d0c53886f..c4a801397 100644
--- a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoConnectionStringHelperTests.cs
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoConnectionStringHelperTests.cs
@@ -45,6 +45,16 @@ public void GetEndpointFromConnectionString_ReturnsNull_WhenEndpointIsNotValidUr
Assert.Null(result);
}
+ [Theory]
+ [InlineData("ftp://logto.example.com")]
+ [InlineData("file:///tmp/logto")]
+ public void GetEndpointFromConnectionString_ReturnsNull_WhenEndpointIsNotHttp(string connectionString)
+ {
+ var result = LogtoConnectionStringHelper.GetEndpointFromConnectionString(connectionString);
+
+ Assert.Null(result);
+ }
+
[Fact]
public void GetEndpointFromConnectionString_ReturnsNull_WhenEndpointKeyMissing()
{
diff --git a/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoJwtBearerBuilderTests.cs b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoJwtBearerBuilderTests.cs
new file mode 100644
index 000000000..4dec798ed
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Logto.Client.Tests/LogtoJwtBearerBuilderTests.cs
@@ -0,0 +1,48 @@
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+
+namespace CommunityToolkit.Aspire.Logto.Client.Tests;
+
+public class LogtoJwtBearerBuilderTests
+{
+ [Fact]
+ public void AddLogtoJwtBearer_ConfiguresIssuerAudiencesAndUserOptions()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Configuration.AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:logto"] = "Endpoint=https://logto.example.com/"
+ });
+
+ builder.Services.AddAuthentication()
+ .AddLogtoJwtBearer(
+ "logto",
+ ["api-one", "api-two"],
+ authenticationScheme: "LogtoBearer",
+ configureOptions: options => options.RequireHttpsMetadata = false);
+
+ using var app = builder.Build();
+ var options = app.Services.GetRequiredService>().Get("LogtoBearer");
+
+ Assert.Equal("https://logto.example.com/oidc", options.Authority);
+ Assert.Equal("https://logto.example.com/oidc", options.TokenValidationParameters.ValidIssuer);
+ Assert.Equal(["api-one", "api-two"], options.TokenValidationParameters.ValidAudiences);
+ Assert.True(options.TokenValidationParameters.ValidateIssuer);
+ Assert.True(options.TokenValidationParameters.ValidateAudience);
+ Assert.False(options.RequireHttpsMetadata);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void AddLogtoJwtBearer_RejectsEmptyAudience(string audience)
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ Assert.Throws(() =>
+ builder.Services.AddAuthentication().AddLogtoJwtBearer("logto", audience));
+ }
+}