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