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)); + } +}