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/Directory.Packages.props b/Directory.Packages.props
index 7048bcf0..ea8ccb57 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,6 +8,7 @@
+
@@ -30,6 +31,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..3bcc3742
--- /dev/null
+++ b/src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs
@@ -0,0 +1,133 @@
+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/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/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..e03e5bb2 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,16 +13,52 @@
// 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();
-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/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.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..a9085492 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..f42778e2 100644
--- a/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj
+++ b/test/AdaptiveRemote.Backend.ApiTests/AdaptiveRemote.Backend.ApiTests.csproj
@@ -10,10 +10,13 @@
-
+
+
+
+
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/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
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..a05241fb 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()
+ public async Task GivenCompiledLayoutServiceIsRunning()
{
- _fixture.StartService();
+ await _context.Fixture.StartServiceAsync();
}
[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();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs
index ea39cfa4..25b9f59b 100644
--- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs
+++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs
@@ -1,4 +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;
@@ -6,23 +9,44 @@ 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";
+ 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)
{
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;
@@ -49,7 +73,9 @@ public void StartService()
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,
@@ -57,7 +83,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,41 +117,92 @@ 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).
+ // 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 = HttpClient.GetAsync("/health").Result;
+ HttpResponseMessage response = await healthClient
+ .GetAsync("/health")
+ .ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
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}");
+ }
}
- TimeSpan sleepTime = TimeSpan.FromSeconds(1000) - (DateTime.Now - startTime);
- if (sleepTime > TimeSpan.Zero)
- {
- Thread.Sleep(sleepTime);
- }
+ await Task.Delay(1000).ConfigureAwait(false);
}
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.
+ 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("StartServiceAsync() must be called before CreateToken()");
+ }
+
+ return _jwtAuthority.CreateToken(sub);
+ }
+
+ ///
+ /// Creates an expired JWT.
+ ///
+ public string CreateExpiredToken()
+ {
+ if (_jwtAuthority is null)
+ {
+ throw new InvalidOperationException("StartServiceAsync() 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 +221,38 @@ public void Dispose()
}
HttpClient?.Dispose();
+ _jwtAuthority?.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.
+ ///
+ 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();
+ }
+}