From 515403778fe86b10e33666e1b15842bf4204251f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 19:13:17 +0000 Subject: [PATCH 1/6] [ADR-168] Wire JWT Bearer auth via Cognito; add CognitoTokenService and auth API tests - CompiledLayoutService: add JWT Bearer authentication backed by AWS Cognito. GET /layouts/compiled/active now requires a valid bearer token; GET /health remains unauthenticated. The sub claim is extracted as userId. CognitoSettings reads Authority and Audience from appsettings/env vars. - Client app: add BackendSettings, ICognitoTokenService, CognitoTokenService. CognitoTokenService acquires and caches tokens via the OAuth2 Client Credentials flow (lazy acquire, 60-second expiry buffer). Log messages in range 1600-1699. - API tests: add TestJwtAuthority (local OIDC/JWKS server) so auth can be tested end-to-end without a real Cognito user pool. ServiceFixture now starts the authority first and configures Cognito__Authority on the service process. CommonSteps updated to use Reqnroll context injection (ServiceContext) for shared fixture state. AuthenticationEndpoints.feature covers 401 for no-auth and expired tokens, 200 for valid JWT, and unauthenticated /health access. - Docs: _doc_Auth.md documents Cognito setup for local dev, client credentials config, editor Authorization Code flow, and the test JWT authority pattern. https://claude.ai/code/session_01LLWQBraEp7n7uLVy7M4PFp --- Directory.Packages.props | 2 + docker-compose.yml | 4 + .../AppHostBuilderExtensions.cs | 1 + .../BackendHostBuilderExtensions.cs | 20 ++ .../Configuration/SettingsKeys.cs | 5 + .../Logging/MessageLogger.cs | 11 + .../Services/Backend/BackendSettings.cs | 52 +++++ .../Services/Backend/CognitoTokenService.cs | 134 +++++++++++++ .../Services/Backend/ICognitoTokenService.cs | 15 ++ .../Configuration/CognitoSettings.cs | 19 ++ .../Endpoints/LayoutEndpoints.cs | 18 +- .../Logging/MessageLogger.cs | 4 +- .../Program.cs | 34 ++++ .../_doc_Auth.md | 90 +++++++++ .../appsettings.Development.json | 4 + .../appsettings.json | 6 +- src/AdaptiveRemote/AdaptiveRemote.csproj | 5 + .../appsettings.Development.json | 11 + src/AdaptiveRemote/appsettings.json | 11 + src/_doc_Projects.md | 9 + .../AdaptiveRemote.Backend.ApiTests.csproj | 1 + .../Features/AuthenticationEndpoints.feature | 21 ++ .../StepDefinitions/AuthenticationSteps.cs | 57 ++++++ .../StepDefinitions/CommonSteps.cs | 46 +++-- .../Support/ServiceContext.cs | 26 +++ .../Support/ServiceFixture.cs | 91 ++++++++- .../Support/TestJwtAuthority.cs | 188 ++++++++++++++++++ 27 files changed, 851 insertions(+), 34 deletions(-) create mode 100644 src/AdaptiveRemote.App/Configuration/BackendHostBuilderExtensions.cs create mode 100644 src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs create mode 100644 src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs create mode 100644 src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Configuration/CognitoSettings.cs create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md create mode 100644 src/AdaptiveRemote/appsettings.Development.json create mode 100644 src/AdaptiveRemote/appsettings.json create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature create mode 100644 test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 7048bcf0..44f9afe3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,6 +30,8 @@ + + diff --git a/docker-compose.yml b/docker-compose.yml index 8060fb6e..bba4ad9c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,10 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:8080 + # Set Cognito dev user pool values here or via a .env file. + # See _doc_Auth.md for Cognito dev user pool setup instructions. + - Cognito__Authority=${COGNITO_AUTHORITY:-} + - Cognito__Audience=${COGNITO_AUDIENCE:-} networks: - backend diff --git a/src/AdaptiveRemote.App/AppHostBuilderExtensions.cs b/src/AdaptiveRemote.App/AppHostBuilderExtensions.cs index 5f6b9ca4..980c2daa 100644 --- a/src/AdaptiveRemote.App/AppHostBuilderExtensions.cs +++ b/src/AdaptiveRemote.App/AppHostBuilderExtensions.cs @@ -15,6 +15,7 @@ public static IHostBuilder ConfigureApp(this IHostBuilder hostBuilder) .AddTiVoSupport() .AddConversationSystem() .AddSystemWrapperServices() + .AddBackendSupport() .OptionallyAddTestHookEndpoint(); public static IHostBuilder ConfigureAppSettings(this IHostBuilder hostBuilder, string[] args) diff --git a/src/AdaptiveRemote.App/Configuration/BackendHostBuilderExtensions.cs b/src/AdaptiveRemote.App/Configuration/BackendHostBuilderExtensions.cs new file mode 100644 index 00000000..dd5f9fdf --- /dev/null +++ b/src/AdaptiveRemote.App/Configuration/BackendHostBuilderExtensions.cs @@ -0,0 +1,20 @@ +using AdaptiveRemote.Services.Backend; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace AdaptiveRemote.Configuration; + +internal static class BackendHostBuilderExtensions +{ + public static IHostBuilder AddBackendSupport(this IHostBuilder builder) + => builder.ConfigureServices((context, services) => + services.AddBackendServices(context.Configuration)); + + private static IServiceCollection AddBackendServices( + this IServiceCollection services, + IConfiguration configuration) + => services + .Configure(configuration.GetSection(SettingsKeys.Backend)) + .AddSingleton(); +} diff --git a/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs b/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs index ec364b45..2fcf1cf3 100644 --- a/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs +++ b/src/AdaptiveRemote.App/Configuration/SettingsKeys.cs @@ -41,4 +41,9 @@ internal class SettingsKeys /// Configuration section for IR command payloads. /// public const string IRData = "irdata"; + + /// + /// Configuration section for backend service settings. + /// + public const string Backend = "backend"; } diff --git a/src/AdaptiveRemote.App/Logging/MessageLogger.cs b/src/AdaptiveRemote.App/Logging/MessageLogger.cs index d8b19c83..9e3b3224 100644 --- a/src/AdaptiveRemote.App/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.App/Logging/MessageLogger.cs @@ -346,4 +346,15 @@ public MessageLogger(ILogger logger) [LoggerMessage(EventId = 1516, Level = LogLevel.Information, Message = "Registering test service {ServiceName} implementing {ContractType} in DI container")] public partial void TestEndpointHooksService_RegisteringTestServiceInDI(string serviceName, string contractType); + + // 1600–1699: CognitoTokenService + + [LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")] + public partial void CognitoTokenService_AcquiringToken(); + + [LoggerMessage(EventId = 1601, Level = LogLevel.Information, Message = "Cognito access token acquired successfully")] + public partial void CognitoTokenService_TokenAcquired(); + + [LoggerMessage(EventId = 1602, Level = LogLevel.Error, Message = "Failed to acquire Cognito access token")] + public partial void CognitoTokenService_AcquireTokenFailed(Exception exception); } diff --git a/src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs b/src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs new file mode 100644 index 00000000..849878d4 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs @@ -0,0 +1,52 @@ +namespace AdaptiveRemote.Services.Backend; + +/// +/// Configuration for the AdaptiveRemote backend services. +/// Maps to the "backend" section in appsettings.json. +/// +public class BackendSettings +{ + /// + /// Base URL of the backend API (e.g. https://api.adaptiveremote.example.com for + /// production, or http://localhost:8080 for local development). + /// + public string BaseUrl { get; set; } = string.Empty; + + /// + /// Cognito OAuth2 client credentials for the client application. + /// + public CognitoClientSettings Cognito { get; set; } = new(); +} + +/// +/// AWS Cognito Client Credentials flow settings for the client application. +/// The client application authenticates as a machine client — no interactive login occurs. +/// Sensitive values (ClientSecret) should be stored in user secrets or environment +/// variables, not checked in to source control. +/// +public class CognitoClientSettings +{ + /// + /// The Cognito user pool authority URL, e.g. + /// https://cognito-idp.{region}.amazonaws.com/{userPoolId} + /// Used to discover the token endpoint via OIDC configuration. + /// + public string Authority { get; set; } = string.Empty; + + /// + /// The OAuth2 client ID registered in the Cognito user pool for the client application. + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// The OAuth2 client secret. Store in user secrets or environment variables — + /// never commit to source control. + /// + public string ClientSecret { get; set; } = string.Empty; + + /// + /// OAuth2 scope(s) to request, space-separated (e.g. "adaptiveremote/layouts.read"). + /// Leave empty to omit the scope parameter. + /// + public string? Scope { get; set; } +} diff --git a/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs b/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs new file mode 100644 index 00000000..66d6f35f --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs @@ -0,0 +1,134 @@ +using System.Net.Http; +using System.Text.Json; +using AdaptiveRemote.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AdaptiveRemote.Services.Backend; + +/// +/// Acquires and caches OAuth2 access tokens from AWS Cognito using the +/// Client Credentials flow. Token refresh is lazy: the cached token is +/// returned until it is within of expiring, +/// at which point a new token is acquired. +/// +internal sealed class CognitoTokenService : ICognitoTokenService, IDisposable +{ + // Refresh the token this many seconds before it actually expires. + private static readonly TimeSpan ExpiryBuffer = TimeSpan.FromSeconds(60); + + private readonly BackendSettings _settings; + private readonly HttpClient _httpClient; + private readonly MessageLogger _log; + + private string? _cachedToken; + private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue; + private string? _tokenEndpoint; + private readonly SemaphoreSlim _lock = new(1, 1); + + public CognitoTokenService( + IOptions settings, + ILogger logger) + { + _settings = settings.Value; + _httpClient = new HttpClient(); + _log = new MessageLogger(logger); + } + + public async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + await _lock.WaitAsync(cancellationToken); + try + { + if (_cachedToken is not null && DateTimeOffset.UtcNow < _tokenExpiry - ExpiryBuffer) + { + return _cachedToken; + } + + _log.CognitoTokenService_AcquiringToken(); + try + { + string endpoint = await GetTokenEndpointAsync(cancellationToken); + (_cachedToken, _tokenExpiry) = await AcquireTokenAsync(endpoint, cancellationToken); + _log.CognitoTokenService_TokenAcquired(); + return _cachedToken; + } + catch (Exception ex) + { + _log.CognitoTokenService_AcquireTokenFailed(ex); + throw; + } + } + finally + { + _lock.Release(); + } + } + + private async Task GetTokenEndpointAsync(CancellationToken cancellationToken) + { + if (_tokenEndpoint is not null) + { + return _tokenEndpoint; + } + + CognitoClientSettings cognito = _settings.Cognito; + string discoveryUrl = $"{cognito.Authority.TrimEnd('/')}/.well-known/openid-configuration"; + + using HttpResponseMessage discoveryResponse = + await _httpClient.GetAsync(discoveryUrl, cancellationToken); + + discoveryResponse.EnsureSuccessStatusCode(); + + string json = await discoveryResponse.Content.ReadAsStringAsync(cancellationToken); + using JsonDocument doc = JsonDocument.Parse(json); + _tokenEndpoint = doc.RootElement.GetProperty("token_endpoint").GetString() + ?? throw new InvalidOperationException( + "token_endpoint not found in OIDC discovery document"); + + return _tokenEndpoint; + } + + private async Task<(string Token, DateTimeOffset Expiry)> AcquireTokenAsync( + string endpoint, + CancellationToken cancellationToken) + { + CognitoClientSettings cognito = _settings.Cognito; + + List> parameters = + [ + new("grant_type", "client_credentials"), + new("client_id", cognito.ClientId), + new("client_secret", cognito.ClientSecret), + ]; + + if (!string.IsNullOrEmpty(cognito.Scope)) + { + parameters.Add(new("scope", cognito.Scope)); + } + + using FormUrlEncodedContent content = new(parameters); + using HttpResponseMessage response = + await _httpClient.PostAsync(endpoint, content, cancellationToken); + + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync(cancellationToken); + using JsonDocument doc = JsonDocument.Parse(json); + + string accessToken = doc.RootElement.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("access_token not found in token response"); + + int expiresIn = doc.RootElement.TryGetProperty("expires_in", out JsonElement expiresInElement) + ? expiresInElement.GetInt32() + : 3600; + + return (accessToken, DateTimeOffset.UtcNow.AddSeconds(expiresIn)); + } + + public void Dispose() + { + _httpClient.Dispose(); + _lock.Dispose(); + } +} diff --git a/src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs b/src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs new file mode 100644 index 00000000..d9431e27 --- /dev/null +++ b/src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs @@ -0,0 +1,15 @@ +namespace AdaptiveRemote.Services.Backend; + +/// +/// Acquires and caches OAuth2 access tokens from AWS Cognito using the +/// Client Credentials flow. The client application calls this to obtain a +/// bearer token before making requests to backend services. +/// +public interface ICognitoTokenService +{ + /// + /// Returns a valid access token, acquiring or refreshing it from Cognito + /// as needed. The returned token is safe to use as a Bearer token immediately. + /// + Task GetAccessTokenAsync(CancellationToken cancellationToken); +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Configuration/CognitoSettings.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Configuration/CognitoSettings.cs new file mode 100644 index 00000000..659b87d8 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Configuration/CognitoSettings.cs @@ -0,0 +1,19 @@ +namespace AdaptiveRemote.Backend.CompiledLayoutService.Configuration; + +/// +/// Configuration for AWS Cognito JWT validation. +/// Maps to the "Cognito" section in appsettings.json. +/// +public class CognitoSettings +{ + /// + /// The Cognito user pool authority URL, e.g. + /// https://cognito-idp.{region}.amazonaws.com/{userPoolId} + /// + public string Authority { get; set; } = string.Empty; + + /// + /// The OAuth2 audience (app client ID). If empty, audience validation is skipped. + /// + public string? Audience { get; set; } +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs index 665016a3..e18b116a 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Endpoints/LayoutEndpoints.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using AdaptiveRemote.Backend.CompiledLayoutService.Logging; using AdaptiveRemote.Contracts; @@ -9,18 +10,26 @@ public static void MapLayoutEndpoints(this IEndpointRouteBuilder app) { app.MapGet("/layouts/compiled/active", GetActiveLayout) .WithName(nameof(GetActiveLayout)) - .Produces(StatusCodes.Status200OK); + .Produces(StatusCodes.Status200OK) + .RequireAuthorization(); } private static async Task GetActiveLayout( + ClaimsPrincipal user, ILogger logger, ICompiledLayoutRepository repository, CancellationToken cancellationToken) { - logger.GetActiveLayoutRequested(); + string? userId = user.FindFirst("sub")?.Value; + if (userId is null) + { + // Should not happen when RequireAuthorization() is in effect and the token + // is a valid Cognito JWT, but guard defensively. + return Results.Unauthorized(); + } + + logger.GetActiveLayoutRequested(userId); - // For MVP, we use a hardcoded userId. Auth will provide real userId in ADR-168. - string userId = "mvp-user"; CompiledLayout? layout = await repository.GetActiveForUserAsync(userId, cancellationToken); if (layout == null) @@ -30,7 +39,6 @@ private static async Task GetActiveLayout( logger.ReturningActiveLayout(layout.Id); - // Use the LayoutContractsJsonContext for serialization return Results.Json( layout, LayoutContractsJsonContext.Default.CompiledLayout); diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs index 6f926c09..761f67ff 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs @@ -16,8 +16,8 @@ public static partial class MessageLogger [LoggerMessage(EventId = 1101, Level = LogLevel.Information, Message = "CompiledLayoutService started successfully on {ListenAddress}")] public static partial void ServiceStarted(this ILogger logger, string listenAddress); - [LoggerMessage(EventId = 1102, Level = LogLevel.Information, Message = "GET /layouts/compiled/active request received")] - public static partial void GetActiveLayoutRequested(this ILogger logger); + [LoggerMessage(EventId = 1102, Level = LogLevel.Information, Message = "GET /layouts/compiled/active request received for userId={UserId}")] + public static partial void GetActiveLayoutRequested(this ILogger logger, string userId); [LoggerMessage(EventId = 1103, Level = LogLevel.Information, Message = "Returning active compiled layout Id={LayoutId}")] public static partial void ReturningActiveLayout(this ILogger logger, Guid layoutId); diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index 0c642748..dea4fdda 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -1,7 +1,9 @@ +using AdaptiveRemote.Backend.CompiledLayoutService.Configuration; using AdaptiveRemote.Backend.CompiledLayoutService.Endpoints; using AdaptiveRemote.Backend.CompiledLayoutService.Logging; using AdaptiveRemote.Backend.CompiledLayoutService.Services; using AdaptiveRemote.Contracts; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -11,11 +13,43 @@ // Register services builder.Services.AddSingleton(); +// Configure JWT Bearer authentication with AWS Cognito +CognitoSettings cognitoSettings = builder.Configuration + .GetSection("Cognito") + .Get() ?? new CognitoSettings(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = cognitoSettings.Authority; + + if (!string.IsNullOrEmpty(cognitoSettings.Audience)) + { + options.Audience = cognitoSettings.Audience; + } + else + { + // When no audience is configured, skip audience validation. + options.TokenValidationParameters.ValidateAudience = false; + } + + // Preserve original claim names from the JWT (don't remap to .NET claim types). + options.MapInboundClaims = false; + + // Allow HTTP metadata endpoints in non-production environments (local dev and tests). + options.RequireHttpsMetadata = builder.Environment.IsProduction(); + }); + +builder.Services.AddAuthorization(); + WebApplication app = builder.Build(); ILogger logger = app.Services.GetRequiredService>(); logger.ServiceStarting(); +app.UseAuthentication(); +app.UseAuthorization(); + // Map endpoints app.MapHealthEndpoints(); app.MapLayoutEndpoints(); diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md new file mode 100644 index 00000000..843ce8e5 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md @@ -0,0 +1,90 @@ +# Authentication — CompiledLayoutService (ADR-168) + +## Overview + +External API endpoints are protected by JWT Bearer authentication backed by **AWS Cognito**. +The service validates bearer tokens on every protected request; `/health` is unauthenticated. + +The `sub` claim from the validated JWT is used as the `userId` throughout the service. + +## Authentication flows + +| Client | Flow | Trigger | +|--------|------|---------| +| Client application (WPF) | OAuth2 **Client Credentials** | Unattended machine; acquires token at startup, refreshes automatically | +| Editor application (Blazor WASM) | OAuth2 **Authorization Code** | Browser-based interactive login via Cognito Hosted UI | + +## Cognito dev user pool setup + +1. Create a Cognito user pool in your AWS dev account. +2. Under **App clients**, create two app clients: + - `adaptiveremote-client` — enable Client Credentials flow; note `client_id` and `client_secret`. + - `adaptiveremote-editor` — enable Authorization Code flow; configure allowed callback URL. +3. Create a resource server (custom scope), e.g. `adaptiveremote/layouts.read`. +4. Note the user pool's **Issuer URL** (shown in the pool's details page): + `https://cognito-idp.{region}.amazonaws.com/{userPoolId}` + +## Configuring the backend service + +Set these environment variables (or values in `appsettings.Development.json` — never commit secrets): + +| Variable | Example | +|----------|---------| +| `Cognito__Authority` | `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123` | +| `Cognito__Audience` | `` (optional; leave empty to skip audience validation) | + +For local development via `docker-compose`, set `COGNITO_AUTHORITY` and `COGNITO_AUDIENCE` in a +`.env` file at the repository root (excluded from source control by `.gitignore`). + +## Configuring the client application (Client Credentials) + +Set in `appsettings.Development.json` (non-secret values) and user secrets (secrets): + +```json +{ + "backend": { + "baseUrl": "http://localhost:8080", + "cognito": { + "authority": "https://cognito-idp.{region}.amazonaws.com/{userPoolId}", + "clientId": "YOUR_CLIENT_ID", + "scope": "adaptiveremote/layouts.read" + } + } +} +``` + +Add `clientSecret` to user secrets only: +```bash +dotnet user-secrets set "backend:cognito:clientSecret" "YOUR_CLIENT_SECRET" \ + --project src/AdaptiveRemote/AdaptiveRemote.csproj +``` + +The `CognitoTokenService` in `src/AdaptiveRemote.App/Services/Backend/` discovers the token +endpoint from the OIDC configuration document and acquires/refreshes tokens automatically. + +## Configuring the editor application (Authorization Code) + +The editor application (Blazor WASM — separate epic) uses the Cognito Hosted UI for login. +The required configuration is: + +1. Set the `adaptiveremote-editor` app client's callback URL to the editor's redirect URI. +2. Configure the editor app with `cognitoAuthorizeUrl`, `clientId`, and `redirectUri` (no + client secret — public client, PKCE required). +3. On sign-in, the Cognito Hosted UI redirects back with an authorization code; the editor + exchanges it for tokens using PKCE. + +Full setup instructions will be added to the editor epic's documentation when implemented. + +## Internal endpoints + +`LayoutCompilerService` and `LayoutValidationService` are hosted as **AWS Lambda functions** +with **Lambda Function URLs**. These URLs are not exposed via API Gateway and are accessible +only from within the ECS cluster (network isolation via VPC/security groups). No bearer token +validation is required or expected on internal Lambda endpoints. + +## API integration tests + +Tests use a `TestJwtAuthority` (`test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs`), +a minimal local OIDC/JWKS server started per scenario. The service is configured with +`Cognito__Authority` pointing at this server, so JWT validation runs end-to-end without a +real Cognito user pool. See `AuthenticationEndpoints.feature` for the test scenarios. diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json index 34f00ef1..3ae5de58 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json @@ -4,5 +4,9 @@ "Default": "Debug", "Microsoft.AspNetCore": "Information" } + }, + "Cognito": { + "Authority": "", + "Audience": "" } } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json index 10f68b8c..a013ae07 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Cognito": { + "Authority": "", + "Audience": "" + } } diff --git a/src/AdaptiveRemote/AdaptiveRemote.csproj b/src/AdaptiveRemote/AdaptiveRemote.csproj index bd80892e..b90ecf1e 100644 --- a/src/AdaptiveRemote/AdaptiveRemote.csproj +++ b/src/AdaptiveRemote/AdaptiveRemote.csproj @@ -20,6 +20,11 @@ + + + + + diff --git a/src/AdaptiveRemote/appsettings.Development.json b/src/AdaptiveRemote/appsettings.Development.json new file mode 100644 index 00000000..27804e97 --- /dev/null +++ b/src/AdaptiveRemote/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "backend": { + "baseUrl": "http://localhost:8080", + "cognito": { + "authority": "", + "clientId": "", + "clientSecret": "", + "scope": "" + } + } +} diff --git a/src/AdaptiveRemote/appsettings.json b/src/AdaptiveRemote/appsettings.json new file mode 100644 index 00000000..a262e854 --- /dev/null +++ b/src/AdaptiveRemote/appsettings.json @@ -0,0 +1,11 @@ +{ + "backend": { + "baseUrl": "", + "cognito": { + "authority": "", + "clientId": "", + "clientSecret": "", + "scope": "" + } + } +} diff --git a/src/_doc_Projects.md b/src/_doc_Projects.md index e81f19de..97fb460a 100644 --- a/src/_doc_Projects.md +++ b/src/_doc_Projects.md @@ -41,6 +41,15 @@ This document describes the high-level organization of the AdaptiveRemote reposi - No MVVM or runtime behavior — DTOs only. - Included in both `client.slnf` and `backend.slnf`. +## Backend Projects + +Backend services live under `src/` alongside client projects. Use `backend.slnf` to build only the backend set. See [`_spec_LayoutCustomizationService.md`](_spec_LayoutCustomizationService.md) for the full architecture. + +### AdaptiveRemote.Backend.CompiledLayoutService +- **Purpose:** Serves compiled layouts to the client application via REST API. +- **Authentication:** JWT Bearer via AWS Cognito. See [`AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md`](AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md). +- **Pattern:** All backend services follow the logging, health endpoint, and structured log patterns established here (see ADR-167/ADR-168). + ## Test Projects ### AdaptiveRemote.App.Tests diff --git a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj index d8f66699..60188f9c 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -14,6 +14,7 @@ + diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature new file mode 100644 index 00000000..61d24d7b --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature @@ -0,0 +1,21 @@ +Feature: CompiledLayoutService Authentication + +Scenario: Unauthenticated request is rejected + Given CompiledLayoutService is running + When a test client with no Authorization header calls GET /layouts/compiled/active + Then the response is 401 Unauthorized + +Scenario: Request with valid JWT is accepted + Given CompiledLayoutService is running + When a test client with a valid JWT calls GET /layouts/compiled/active + Then the response is 200 OK + +Scenario: Request with expired JWT is rejected + Given CompiledLayoutService is running + When a test client with an expired JWT calls GET /layouts/compiled/active + Then the response is 401 Unauthorized + +Scenario: Health endpoint is accessible without authentication + Given CompiledLayoutService is running + When a test client with no Authorization header calls GET /health + Then the response is 200 OK diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs new file mode 100644 index 00000000..7def9989 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/AuthenticationSteps.cs @@ -0,0 +1,57 @@ +using System.Net; +using AdaptiveRemote.Backend.ApiTests.Support; +using FluentAssertions; +using Reqnroll; + +namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; + +[Binding] +public class AuthenticationSteps : IDisposable +{ + private readonly ServiceContext _context; + + public AuthenticationSteps(ServiceContext context) + { + _context = context; + } + + [When(@"a test client with no Authorization header calls GET (.*)")] + public async Task WhenAnonymousClientCallsGet(string endpoint) + { + using HttpClient client = _context.Fixture.CreateAnonymousHttpClient(); + _context.LastResponse = await client.GetAsync(endpoint); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client with a valid JWT calls GET (.*)")] + public async Task WhenAuthenticatedClientCallsGet(string endpoint) + { + string token = _context.Fixture.CreateToken(); + using HttpClient client = _context.Fixture.CreateBearerHttpClient(token); + _context.LastResponse = await client.GetAsync(endpoint); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [When(@"a test client with an expired JWT calls GET (.*)")] + public async Task WhenExpiredJwtClientCallsGet(string endpoint) + { + string token = _context.Fixture.CreateExpiredToken(); + using HttpClient client = _context.Fixture.CreateBearerHttpClient(token); + _context.LastResponse = await client.GetAsync(endpoint); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); + } + + [Then(@"the response is (\d+) Unauthorized")] + public void ThenTheResponseIsUnauthorized(int statusCode) + { + _context.LastResponse.Should().NotBeNull(); + ((int)_context.LastResponse!.StatusCode).Should().Be(statusCode); + _context.LastResponse.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + public void Dispose() + { + // ServiceContext owns LastResponse and Fixture; nothing to dispose here. + GC.SuppressFinalize(this); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs index 1481184b..057670ae 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs @@ -10,37 +10,40 @@ namespace AdaptiveRemote.Backend.ApiTests.StepDefinitions; [Binding] public class CommonSteps : IDisposable { - private readonly ServiceFixture _fixture = new(); - private HttpResponseMessage? _response; - private string? _responseBody; + private readonly ServiceContext _context; + + public CommonSteps(ServiceContext context) + { + _context = context; + } [Given(@"CompiledLayoutService is running")] public void GivenCompiledLayoutServiceIsRunning() { - _fixture.StartService(); + _context.Fixture.StartService(); } [When(@"a test client calls GET (.*)")] public async Task WhenATestClientCallsGet(string endpoint) { - _response = await _fixture.HttpClient.GetAsync(endpoint); - _responseBody = await _response.Content.ReadAsStringAsync(); + _context.LastResponse = await _context.Fixture.HttpClient.GetAsync(endpoint); + _context.LastResponseBody = await _context.LastResponse.Content.ReadAsStringAsync(); } [Then(@"the response is (\d+) OK")] public void ThenTheResponseIsOk(int statusCode) { - _response.Should().NotBeNull(); - ((int)_response!.StatusCode).Should().Be(statusCode); + _context.LastResponse.Should().NotBeNull(); + ((int)_context.LastResponse!.StatusCode).Should().Be(statusCode); } [Then(@"the body deserializes to a valid CompiledLayout using LayoutContractsJsonContext")] public void ThenTheBodyDeserializesToValidCompiledLayout() { - _responseBody.Should().NotBeNullOrEmpty(); + _context.LastResponseBody.Should().NotBeNullOrEmpty(); CompiledLayout? layout = JsonSerializer.Deserialize( - _responseBody!, + _context.LastResponseBody!, LayoutContractsJsonContext.Default.CompiledLayout); layout.Should().NotBeNull(); @@ -51,17 +54,17 @@ public void ThenTheBodyDeserializesToValidCompiledLayout() [Then(@"the CompiledLayout contains the expected hardcoded commands")] public void ThenTheCompiledLayoutContainsExpectedCommands() { - _responseBody.Should().NotBeNullOrEmpty(); + _context.LastResponseBody.Should().NotBeNullOrEmpty(); CompiledLayout? layout = JsonSerializer.Deserialize( - _responseBody!, + _context.LastResponseBody!, LayoutContractsJsonContext.Default.CompiledLayout); layout.Should().NotBeNull(); - + // Verify key commands from StaticCommandGroupProvider exist List commands = ExtractAllCommands(layout!.Elements); - + commands.Should().Contain(c => c.Name == "Up" && c.Type == CommandType.TiVo); commands.Should().Contain(c => c.Name == "Select" && c.Type == CommandType.TiVo); commands.Should().Contain(c => c.Name == "Power" && c.Type == CommandType.IR); @@ -72,14 +75,14 @@ public void ThenTheCompiledLayoutContainsExpectedCommands() [Then(@"the service logs contain a request log entry for GET (.*)")] public void ThenTheServiceLogsContainRequestLogEntry(string endpoint) { - string logs = _fixture.GetLogs(); + string logs = _context.Fixture.GetLogs(); logs.Should().Contain(endpoint); } [Then(@"the service logs contain no warnings or errors")] public void ThenTheServiceLogsContainNoWarningsOrErrors() { - string logs = _fixture.GetLogs(); + string logs = _context.Fixture.GetLogs(); logs.Should().NotContain("WARNING", "service should not log warnings"); logs.Should().NotContain("ERROR", "service should not log errors"); logs.Should().NotContain("Exception", "service should not log exceptions"); @@ -88,10 +91,10 @@ public void ThenTheServiceLogsContainNoWarningsOrErrors() [Then(@"the body contains the service name and version")] public void ThenTheBodyContainsServiceNameAndVersion() { - _responseBody.Should().NotBeNullOrEmpty(); + _context.LastResponseBody.Should().NotBeNullOrEmpty(); HealthResponse? healthResponse = JsonSerializer.Deserialize( - _responseBody!, + _context.LastResponseBody!, LayoutContractsJsonContext.Default.HealthResponse); healthResponse.Should().NotBeNull(); @@ -103,7 +106,7 @@ public void ThenTheBodyContainsServiceNameAndVersion() private static List ExtractAllCommands(IReadOnlyList elements) { List commands = new(); - + foreach (LayoutElementDto element in elements) { if (element is CommandDefinitionDto command) @@ -115,14 +118,13 @@ private static List ExtractAllCommands(IReadOnlyList +/// Reqnroll context-injection container shared across all step definition classes +/// within a single scenario. +/// +/// Holds: +/// - : the running service instance +/// - / : the most recent +/// HTTP response, set by When steps and read by Then steps. +/// +/// Reqnroll creates one instance per scenario and disposes it at scenario end. +/// +public class ServiceContext : IDisposable +{ + public ServiceFixture Fixture { get; } = new(); + + public HttpResponseMessage? LastResponse { get; set; } + public string? LastResponseBody { get; set; } + + public void Dispose() + { + LastResponse?.Dispose(); + Fixture.Dispose(); + } +} diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs index ea39cfa4..77a996e7 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Net.Http.Headers; using System.Text; namespace AdaptiveRemote.Backend.ApiTests.Support; @@ -6,14 +7,27 @@ namespace AdaptiveRemote.Backend.ApiTests.Support; /// /// Manages the lifecycle of CompiledLayoutService for API integration tests. /// Starts the service process and captures structured log output. +/// +/// A is started before the service so that the +/// service can be configured with a real (but local) JWT authority. The +/// exposed to tests automatically includes a valid +/// bearer token. For authentication-specific tests, use +/// and to build +/// tokens, and send them via or +/// directly. /// public class ServiceFixture : IDisposable { private Process? _serviceProcess; private readonly StringBuilder _logOutput = new(); private readonly object _logLock = new(); + private TestJwtAuthority? _jwtAuthority; public string ServiceUrl { get; private set; } = "http://localhost:5000"; + + /// + /// HttpClient pre-configured with a valid bearer token for the test user. + /// public HttpClient HttpClient { get; private set; } = null!; public void StartService() @@ -23,6 +37,9 @@ public void StartService() return; // Already started } + // Start the JWT authority first so its URL is available for service configuration. + _jwtAuthority = new TestJwtAuthority(); + // Find the repository root by looking for the .git directory string currentDir = Directory.GetCurrentDirectory(); string? repoRoot = currentDir; @@ -57,7 +74,9 @@ public void StartService() Environment = { ["ASPNETCORE_ENVIRONMENT"] = "Development", - ["ASPNETCORE_URLS"] = ServiceUrl + ["ASPNETCORE_URLS"] = ServiceUrl, + // Point the service at the local test JWT authority. + ["Cognito__Authority"] = _jwtAuthority.Authority, } }; @@ -89,8 +108,8 @@ public void StartService() _serviceProcess.BeginOutputReadLine(); _serviceProcess.BeginErrorReadLine(); - // Wait for service to be ready - poll for health endpoint - HttpClient = new HttpClient { BaseAddress = new Uri(ServiceUrl) }; + // Poll /health with a temporary unauthenticated client (/health is open). + using HttpClient healthClient = new() { BaseAddress = new Uri(ServiceUrl) }; bool isReady = false; for (int i = 0; i < 30 && !_serviceProcess.HasExited; i++) @@ -98,7 +117,7 @@ public void StartService() DateTime startTime = DateTime.Now; try { - HttpResponseMessage response = HttpClient.GetAsync("/health").Result; + HttpResponseMessage response = healthClient.GetAsync("/health").Result; if (response.IsSuccessStatusCode) { isReady = true; @@ -122,8 +141,49 @@ public void StartService() string logs = GetLogs(); throw new InvalidOperationException($"Service failed to start within 30 seconds. Logs:\n{logs}"); } + + // Default HttpClient includes a valid bearer token for the standard test user. + HttpClient = CreateBearerHttpClient(CreateToken()); + } + + /// + /// Creates a valid JWT for the given subject (default: "test-user"). + /// + public string CreateToken(string sub = "test-user") + { + if (_jwtAuthority is null) + { + throw new InvalidOperationException("StartService() must be called before CreateToken()"); + } + + return _jwtAuthority.CreateToken(sub); + } + + /// + /// Creates an expired JWT. + /// + public string CreateExpiredToken() + { + if (_jwtAuthority is null) + { + throw new InvalidOperationException("StartService() must be called before CreateExpiredToken()"); + } + + return _jwtAuthority.CreateExpiredToken(); } + /// + /// Creates an HttpClient with no Authorization header (for testing 401 responses). + /// + public HttpClient CreateAnonymousHttpClient() + => new() { BaseAddress = new Uri(ServiceUrl) }; + + /// + /// Creates an HttpClient that sends the given bearer token on every request. + /// + public HttpClient CreateBearerHttpClient(string token) + => new(new BearerTokenHandler(token)) { BaseAddress = new Uri(ServiceUrl) }; + public string GetLogs() { lock (_logLock) @@ -142,6 +202,29 @@ public void Dispose() } HttpClient?.Dispose(); + _jwtAuthority?.Dispose(); GC.SuppressFinalize(this); } + + /// + /// Adds a bearer token to every outgoing request. + /// + private sealed class BearerTokenHandler : DelegatingHandler + { + private readonly string _token; + + public BearerTokenHandler(string token) + : base(new HttpClientHandler()) + { + _token = token; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return base.SendAsync(request, cancellationToken); + } + } } diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs new file mode 100644 index 00000000..226f948a --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs @@ -0,0 +1,188 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.IdentityModel.Tokens; + +namespace AdaptiveRemote.Backend.ApiTests.Support; + +/// +/// A minimal local OIDC/JWKS authority used by API integration tests to issue and +/// validate JWTs without a real Cognito user pool. +/// +/// Exposes two endpoints on a dynamically-assigned localhost port: +/// GET /.well-known/openid-configuration — OIDC discovery document +/// GET /.well-known/jwks.json — RSA public key in JWK format +/// +/// The service under test is configured to use this authority via the +/// Cognito__Authority environment variable so that bearer token validation +/// is exercised end-to-end without external dependencies. +/// +public sealed class TestJwtAuthority : IDisposable +{ + private const string TestAudience = "api-tests"; + + private readonly RSA _rsa; + private readonly RsaSecurityKey _signingKey; + private readonly string _keyId; + private readonly HttpListener _listener; + private readonly Thread _listenerThread; + private volatile bool _stopping; + + /// The authority URL the service under test should be configured with. + public string Authority { get; } + + public TestJwtAuthority() + { + int port = GetFreePort(); + Authority = $"http://localhost:{port}"; + + _rsa = RSA.Create(2048); + _keyId = Guid.NewGuid().ToString("N")[..8]; + _signingKey = new RsaSecurityKey(_rsa) { KeyId = _keyId }; + + _listener = new HttpListener(); + _listener.Prefixes.Add($"{Authority}/"); + _listener.Start(); + + _listenerThread = new Thread(HandleRequests) { IsBackground = true, Name = "TestJwtAuthority" }; + _listenerThread.Start(); + } + + /// + /// Creates a signed JWT with the given subject claim, valid for one hour. + /// + public string CreateToken(string sub) + => CreateTokenCore(sub, expired: false); + + /// + /// Creates a signed JWT that is already expired (issued/expiry in the past). + /// + public string CreateExpiredToken(string sub = "test-user") + => CreateTokenCore(sub, expired: true); + + private string CreateTokenCore(string sub, bool expired) + { + DateTime now = DateTime.UtcNow; + DateTime notBefore = expired ? now.AddHours(-2) : now.AddSeconds(-5); + DateTime expires = expired ? now.AddHours(-1) : now.AddHours(1); + + JwtSecurityTokenHandler handler = new(); + JwtSecurityToken token = handler.CreateJwtSecurityToken( + issuer: Authority, + audience: TestAudience, + subject: new ClaimsIdentity([new Claim("sub", sub)]), + notBefore: notBefore, + expires: expires, + signingCredentials: new SigningCredentials(_signingKey, SecurityAlgorithms.RsaSha256)); + + return handler.WriteToken(token); + } + + private void HandleRequests() + { + while (!_stopping) + { + HttpListenerContext context; + try + { + context = _listener.GetContext(); + } + catch (HttpListenerException) when (_stopping) + { + return; + } + catch (ObjectDisposedException) + { + return; + } + + try + { + HandleRequest(context); + } + catch + { + // Do not crash the listener thread on individual request errors. + } + } + } + + private void HandleRequest(HttpListenerContext context) + { + string path = context.Request.Url?.AbsolutePath ?? string.Empty; + context.Response.ContentType = "application/json"; + + byte[] body = path switch + { + "/.well-known/openid-configuration" => BuildDiscoveryDocument(), + "/.well-known/jwks.json" => BuildJwks(), + _ => BuildNotFound(context), + }; + + context.Response.ContentLength64 = body.Length; + context.Response.OutputStream.Write(body, 0, body.Length); + context.Response.Close(); + } + + private byte[] BuildDiscoveryDocument() + { + string json = JsonSerializer.Serialize(new + { + issuer = Authority, + jwks_uri = $"{Authority}/.well-known/jwks.json", + token_endpoint = $"{Authority}/oauth2/token", + }); + return System.Text.Encoding.UTF8.GetBytes(json); + } + + private byte[] BuildJwks() + { + RSAParameters rsaParams = _rsa.ExportParameters(includePrivateParameters: false); + + string n = Base64UrlEncoder.Encode(rsaParams.Modulus!); + string e = Base64UrlEncoder.Encode(rsaParams.Exponent!); + + string json = JsonSerializer.Serialize(new + { + keys = new[] + { + new + { + kty = "RSA", + use = "sig", + alg = "RS256", + kid = _keyId, + n, + e, + } + } + }); + return System.Text.Encoding.UTF8.GetBytes(json); + } + + private static byte[] BuildNotFound(HttpListenerContext context) + { + context.Response.StatusCode = 404; + return System.Text.Encoding.UTF8.GetBytes("{}"); + } + + private static int GetFreePort() + { + using System.Net.Sockets.TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public void Dispose() + { + _stopping = true; + _listener.Stop(); + _listenerThread.Join(timeout: TimeSpan.FromSeconds(2)); + _listener.Close(); + _rsa.Dispose(); + } +} From 787b0df465cff550c1d8e7b814bf40cf4b92e889 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Thu, 9 Apr 2026 20:28:47 -0700 Subject: [PATCH 2/6] Fix build failures --- Directory.Packages.props | 1 + ...emote.Backend.CompiledLayoutService.csproj | 4 + .../Properties/launchSettings.json | 12 + src/AdaptiveRemote/AdaptiveRemote.csproj | 5 - .../AuthenticationEndpoints.feature.cs | 261 ++++++++++++++++++ 5 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json create mode 100644 test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 44f9afe3..ea8ccb57 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj index b83f9e8f..a2b8d2e2 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -7,6 +7,10 @@ AdaptiveRemote.Backend.CompiledLayoutService + + + + diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json new file mode 100644 index 00000000..665343d2 --- /dev/null +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "AdaptiveRemote.Backend.CompiledLayoutService": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54433;http://localhost:54434" + } + } +} \ No newline at end of file diff --git a/src/AdaptiveRemote/AdaptiveRemote.csproj b/src/AdaptiveRemote/AdaptiveRemote.csproj index b90ecf1e..bd80892e 100644 --- a/src/AdaptiveRemote/AdaptiveRemote.csproj +++ b/src/AdaptiveRemote/AdaptiveRemote.csproj @@ -20,11 +20,6 @@ - - - - - diff --git a/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs new file mode 100644 index 00000000..2fc44389 --- /dev/null +++ b/test/AdaptiveRemote.Backend.ApiTests/Features/AuthenticationEndpoints.feature.cs @@ -0,0 +1,261 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://reqnroll.net/). +// Reqnroll Version:3.0.0.0 +// Reqnroll Generator Version:3.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +using Reqnroll; +namespace AdaptiveRemote.Backend.ApiTests.Features +{ + + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "3.0.0.0")] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute()] + public partial class CompiledLayoutServiceAuthenticationFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private Microsoft.VisualStudio.TestTools.UnitTesting.TestContext _testContext; + + private static string[] featureTags = ((string[])(null)); + + private static global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new global::System.Globalization.CultureInfo("en-US"), "Features", "CompiledLayoutService Authentication", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags, InitializeCucumberMessages()); + +#line 1 "AuthenticationEndpoints.feature" +#line hidden + + public virtual Microsoft.VisualStudio.TestTools.UnitTesting.TestContext TestContext + { + get + { + return this._testContext; + } + set + { + this._testContext = value; + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassInitializeAttribute()] + public static async global::System.Threading.Tasks.Task FeatureSetupAsync(Microsoft.VisualStudio.TestTools.UnitTesting.TestContext testContext) + { + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupAttribute(Microsoft.VisualStudio.TestTools.UnitTesting.ClassCleanupBehavior.EndOfClass)] + public static async global::System.Threading.Tasks.Task FeatureTearDownAsync() + { + await global::Reqnroll.TestRunnerManager.ReleaseFeatureAsync(featureInfo); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestInitializeAttribute()] + public async global::System.Threading.Tasks.Task TestInitializeAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(featureHint: featureInfo); + try + { + if (((testRunner.FeatureContext != null) + && (testRunner.FeatureContext.FeatureInfo.Equals(featureInfo) == false))) + { + await testRunner.OnFeatureEndAsync(); + } + } + finally + { + if (((testRunner.FeatureContext != null) + && testRunner.FeatureContext.BeforeFeatureHookFailed)) + { + throw new global::Reqnroll.ReqnrollException("Scenario skipped because of previous before feature hook error"); + } + if ((testRunner.FeatureContext == null)) + { + await testRunner.OnFeatureStartAsync(featureInfo); + } + } + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCleanupAttribute()] + public async global::System.Threading.Tasks.Task TestTearDownAsync() + { + if ((testRunner == null)) + { + return; + } + try + { + await testRunner.OnScenarioEndAsync(); + } + finally + { + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, global::Reqnroll.RuleInfo ruleInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo, ruleInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testContext); + } + + public async global::System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async global::System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() + { + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("Features/AuthenticationEndpoints.feature.ndjson", 6); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Unauthenticated request is rejected")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Unauthenticated request is rejected")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Authentication")] + public async global::System.Threading.Tasks.Task UnauthenticatedRequestIsRejected() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "0"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Unauthenticated request is rejected", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 3 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 5 + await testRunner.WhenAsync("a test client with no Authorization header calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 6 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Request with valid JWT is accepted")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Request with valid JWT is accepted")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Authentication")] + public async global::System.Threading.Tasks.Task RequestWithValidJWTIsAccepted() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Request with valid JWT is accepted", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 8 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 9 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 10 + await testRunner.WhenAsync("a test client with a valid JWT calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 11 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Request with expired JWT is rejected")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Request with expired JWT is rejected")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Authentication")] + public async global::System.Threading.Tasks.Task RequestWithExpiredJWTIsRejected() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "2"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Request with expired JWT is rejected", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 13 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 14 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 15 + await testRunner.WhenAsync("a test client with an expired JWT calls GET /layouts/compiled/active", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 16 + await testRunner.ThenAsync("the response is 401 Unauthorized", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestMethodAttribute("Health endpoint is accessible without authentication")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.DescriptionAttribute("Health endpoint is accessible without authentication")] + [global::Microsoft.VisualStudio.TestTools.UnitTesting.TestPropertyAttribute("FeatureTitle", "CompiledLayoutService Authentication")] + public async global::System.Threading.Tasks.Task HealthEndpointIsAccessibleWithoutAuthentication() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "3"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Health endpoint is accessible without authentication", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 18 +this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); +#line 19 + await testRunner.GivenAsync("CompiledLayoutService is running", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 20 + await testRunner.WhenAsync("a test client with no Authorization header calls GET /health", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 21 + await testRunner.ThenAsync("the response is 200 OK", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion From fbd82faeb8b93dea607c0f7bad94d800d1dc2b6e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 19:13:17 +0000 Subject: [PATCH 3/6] [ADR-168] Wire JWT Bearer auth via Cognito; add CognitoTokenService and auth API tests - CompiledLayoutService: add JWT Bearer authentication backed by AWS Cognito. GET /layouts/compiled/active now requires a valid bearer token; GET /health remains unauthenticated. The sub claim is extracted as userId. CognitoSettings reads Authority and Audience from appsettings/env vars. - Client app: add BackendSettings, ICognitoTokenService, CognitoTokenService. CognitoTokenService acquires and caches tokens via the OAuth2 Client Credentials flow (lazy acquire, 60-second expiry buffer). Log messages in range 1600-1699. - API tests: add TestJwtAuthority (local OIDC/JWKS server) so auth can be tested end-to-end without a real Cognito user pool. ServiceFixture now starts the authority first and configures Cognito__Authority on the service process. CommonSteps updated to use Reqnroll context injection (ServiceContext) for shared fixture state. AuthenticationEndpoints.feature covers 401 for no-auth and expired tokens, 200 for valid JWT, and unauthenticated /health access. - Docs: _doc_Auth.md documents Cognito setup for local dev, client credentials config, editor Authorization Code flow, and the test JWT authority pattern. https://claude.ai/code/session_01LLWQBraEp7n7uLVy7M4PFp --- src/AdaptiveRemote/AdaptiveRemote.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AdaptiveRemote/AdaptiveRemote.csproj b/src/AdaptiveRemote/AdaptiveRemote.csproj index bd80892e..b90ecf1e 100644 --- a/src/AdaptiveRemote/AdaptiveRemote.csproj +++ b/src/AdaptiveRemote/AdaptiveRemote.csproj @@ -20,6 +20,11 @@ + + + + + From 56c209bde0183f1e8ad2a42d8487cc62d75ef78d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 02:52:14 +0000 Subject: [PATCH 4/6] Fix API test hanging, build errors, and missing package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three categories of fixes: 1. API tests hanging: ServiceFixture.StartService() was blocking the async test thread with Thread.Sleep() and HttpClient.GetAsync().Result, risking a deadlock when called from Reqnroll's async step dispatcher. Renamed to StartServiceAsync(), switched to await/Task.Delay, added a 5-second per- request timeout on the health-check client, and made the [Given] step binding async. The service URL is now allocated on a dynamic free port instead of hardcoded :5000, eliminating port-conflict failures between consecutive scenarios. 2. Build errors: - Removed 'using System.Net.Http;' from CognitoTokenService.cs — the namespace is already a global implicit using for Microsoft.NET.Sdk.Razor and the redundant directive triggers a warning/error with /warnaserror. - Changed to in AdaptiveRemote.csproj so we only set CopyToOutputDirectory metadata on items the SDK already includes automatically, instead of adding duplicate Content items. 3. Missing package: Added Microsoft.NET.Test.Sdk to AdaptiveRemote.Backend. ApiTests.csproj — required by Reqnroll.MSTest for proper test-host infrastructure (consistent with all other test projects in the solution). https://claude.ai/code/session_01LLWQBraEp7n7uLVy7M4PFp --- .../Services/Backend/CognitoTokenService.cs | 1 - src/AdaptiveRemote/AdaptiveRemote.csproj | 4 +- .../AdaptiveRemote.Backend.ApiTests.csproj | 1 + .../StepDefinitions/CommonSteps.cs | 4 +- .../Support/ServiceFixture.cs | 44 +++++++++++++------ 5 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs b/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs index 66d6f35f..3bcc3742 100644 --- a/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs +++ b/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using System.Text.Json; using AdaptiveRemote.Logging; using Microsoft.Extensions.Logging; diff --git a/src/AdaptiveRemote/AdaptiveRemote.csproj b/src/AdaptiveRemote/AdaptiveRemote.csproj index b90ecf1e..a9085492 100644 --- a/src/AdaptiveRemote/AdaptiveRemote.csproj +++ b/src/AdaptiveRemote/AdaptiveRemote.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj index 60188f9c..59ba0958 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -10,6 +10,7 @@ + diff --git a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs index 057670ae..a05241fb 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/StepDefinitions/CommonSteps.cs @@ -18,9 +18,9 @@ public CommonSteps(ServiceContext context) } [Given(@"CompiledLayoutService is running")] - public void GivenCompiledLayoutServiceIsRunning() + public async Task GivenCompiledLayoutServiceIsRunning() { - _context.Fixture.StartService(); + await _context.Fixture.StartServiceAsync(); } [When(@"a test client calls GET (.*)")] diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs index 77a996e7..a874b593 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -1,5 +1,7 @@ using System.Diagnostics; +using System.Net; using System.Net.Http.Headers; +using System.Net.Sockets; using System.Text; namespace AdaptiveRemote.Backend.ApiTests.Support; @@ -14,7 +16,7 @@ namespace AdaptiveRemote.Backend.ApiTests.Support; /// bearer token. For authentication-specific tests, use /// and to build /// tokens, and send them via or -/// directly. +/// directly. /// public class ServiceFixture : IDisposable { @@ -23,14 +25,19 @@ public class ServiceFixture : IDisposable private readonly object _logLock = new(); private TestJwtAuthority? _jwtAuthority; - public string ServiceUrl { get; private set; } = "http://localhost:5000"; + public string ServiceUrl { get; } /// /// HttpClient pre-configured with a valid bearer token for the test user. /// public HttpClient HttpClient { get; private set; } = null!; - public void StartService() + public ServiceFixture() + { + ServiceUrl = $"http://localhost:{GetFreePort()}"; + } + + public async Task StartServiceAsync() { if (_serviceProcess != null) { @@ -109,15 +116,21 @@ public void StartService() _serviceProcess.BeginErrorReadLine(); // Poll /health with a temporary unauthenticated client (/health is open). - using HttpClient healthClient = new() { BaseAddress = new Uri(ServiceUrl) }; + // Use a short per-request timeout so a slow/stuck response doesn't block the loop. + using HttpClient healthClient = new() + { + BaseAddress = new Uri(ServiceUrl), + Timeout = TimeSpan.FromSeconds(5), + }; bool isReady = false; for (int i = 0; i < 30 && !_serviceProcess.HasExited; i++) { - DateTime startTime = DateTime.Now; try { - HttpResponseMessage response = healthClient.GetAsync("/health").Result; + HttpResponseMessage response = await healthClient + .GetAsync("/health") + .ConfigureAwait(false); if (response.IsSuccessStatusCode) { isReady = true; @@ -129,11 +142,7 @@ public void StartService() // Service not ready yet } - TimeSpan sleepTime = TimeSpan.FromSeconds(1000) - (DateTime.Now - startTime); - if (sleepTime > TimeSpan.Zero) - { - Thread.Sleep(sleepTime); - } + await Task.Delay(1000).ConfigureAwait(false); } if (!isReady) @@ -153,7 +162,7 @@ public string CreateToken(string sub = "test-user") { if (_jwtAuthority is null) { - throw new InvalidOperationException("StartService() must be called before CreateToken()"); + throw new InvalidOperationException("StartServiceAsync() must be called before CreateToken()"); } return _jwtAuthority.CreateToken(sub); @@ -166,7 +175,7 @@ public string CreateExpiredToken() { if (_jwtAuthority is null) { - throw new InvalidOperationException("StartService() must be called before CreateExpiredToken()"); + throw new InvalidOperationException("StartServiceAsync() must be called before CreateExpiredToken()"); } return _jwtAuthority.CreateExpiredToken(); @@ -206,6 +215,15 @@ public void Dispose() GC.SuppressFinalize(this); } + private static int GetFreePort() + { + using TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + /// /// Adds a bearer token to every outgoing request. /// From 8d6f0e589e9a442371b54a8438619673ac0d246d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 19:05:40 +0000 Subject: [PATCH 5/6] Fix build errors and workflow: package mismatch, CA1816, and E2E test gating Three build fixes: 1. Package mismatch (NU1608): Replace MSTest meta-package with MSTest.TestAdapter + MSTest.TestFramework in ApiTests project. MSTest 3.1.1 meta-package requires Microsoft.NET.Test.Sdk = 17.6.0 exactly, which conflicts with the 18.0.1 version in Directory.Packages.props. Other test projects already use the individual packages to avoid this. 2. Code analysis error (CA1816): Add GC.SuppressFinalize(this) to ServiceContext.Dispose() as required by CA1816. 3. Workflow: Add id: build to the Build step and change E2E Tests from if: always() to if: always() && steps.build.outcome == 'success'. This ensures E2E tests skip when the build fails, but still run when only unit tests fail (as was the intent of always()). Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/d7976913-3a8a-4c5f-b6ce-5f024fe07d01 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .github/workflows/build-and-test.yml | 3 ++- .../AdaptiveRemote.Backend.ApiTests.csproj | 3 ++- test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 327666d3..087a7481 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -32,6 +32,7 @@ jobs: run: dotnet restore - name: Build + id: build run: dotnet build --no-restore /warnaserror - name: Install Chromium browser for Playwright headless tests @@ -43,7 +44,7 @@ jobs: - name: E2E Tests run: dotnet test --no-build --verbosity normal --logger trx --results-directory "TestResults" --filter "FullyQualifiedName~EndToEndTest" -m:1 - if: always() + if: always() && steps.build.outcome == 'success' - name: Upload E2E Test Logs uses: actions/upload-artifact@v6 diff --git a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj index 59ba0958..f42778e2 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj +++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj @@ -11,7 +11,8 @@ - + + diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs index cdb3825d..beb4b325 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceContext.cs @@ -22,5 +22,6 @@ public void Dispose() { LastResponse?.Dispose(); Fixture.Dispose(); + GC.SuppressFinalize(this); } } From ae59af0f11f9defa1579427a0c516624a1a5ff91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 20:46:19 +0000 Subject: [PATCH 6/6] Fix API test URL mismatch: add --no-launch-profile to prevent launchSettings from overriding ASPNETCORE_URLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: dotnet run reads launchSettings.json and overwrites ASPNETCORE_URLS with applicationUrl (https://localhost:54433;http://localhost:54434), ignoring the dynamic port set in ProcessStartInfo.Environment. The health check polls the dynamic port but the service listens on 54434, so it always times out. Changes: 1. ServiceFixture.cs: Added --no-launch-profile to dotnet run so ASPNETCORE_URLS from the test fixture's environment is respected. 2. ServiceFixture.cs: Added per-attempt diagnostic logging in the health check loop showing the URL polled and the status/exception per attempt. 3. Program.cs: Fixed misleading ServiceStarted log — app.Urls is always empty before Run(), so read ASPNETCORE_URLS from IConfiguration instead. Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/ba6750ee-d018-4f28-b4d0-7d4e309d6f02 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Program.cs | 6 +++++- .../Support/ServiceFixture.cs | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index dea4fdda..e03e5bb2 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -54,7 +54,11 @@ app.MapHealthEndpoints(); app.MapLayoutEndpoints(); -string listenAddress = app.Urls.FirstOrDefault() ?? "http://localhost:5000"; +// Log the configured listen address; fall back to Kestrel's default. +// ASPNETCORE_URLS is the standard env-var; "urls" is the equivalent command-line key. +string listenAddress = app.Configuration["ASPNETCORE_URLS"] + ?? app.Configuration["urls"] + ?? "http://localhost:5000"; logger.ServiceStarted(listenAddress); app.Run(); diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs index a874b593..25b9f59b 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -73,7 +73,9 @@ public async Task StartServiceAsync() ProcessStartInfo startInfo = new() { FileName = "dotnet", - Arguments = $"run --project \"{projectPath}\" --no-build", + // --no-launch-profile prevents launchSettings.json from overriding + // ASPNETCORE_URLS with its applicationUrl setting. + Arguments = $"run --project \"{projectPath}\" --no-build --no-launch-profile", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -136,10 +138,18 @@ public async Task StartServiceAsync() isReady = true; break; } + + lock (_logLock) + { + _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] HTTP {(int)response.StatusCode} from {ServiceUrl}/health"); + } } - catch + catch (Exception ex) { - // Service not ready yet + lock (_logLock) + { + _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] Exception polling {ServiceUrl}/health: {ex.GetType().Name}: {ex.Message}"); + } } await Task.Delay(1000).ConfigureAwait(false); @@ -148,7 +158,7 @@ public async Task StartServiceAsync() if (!isReady) { string logs = GetLogs(); - throw new InvalidOperationException($"Service failed to start within 30 seconds. Logs:\n{logs}"); + throw new InvalidOperationException($"Service failed to start within 30 seconds (polling {ServiceUrl}/health). Logs:\n{logs}"); } // Default HttpClient includes a valid bearer token for the standard test user.