Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.6.0" />
<!-- ASP.NET Core and Web Components -->
<PackageVersion Include="Markdig" Version="0.40.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.22" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="8.0.100" />
<!-- Microsoft Extensions -->
Expand All @@ -30,6 +31,8 @@
<PackageVersion Include="Microsoft.VisualStudio.Threading" Version="17.14.15" />
</ItemGroup>
<ItemGroup Label="Test-Only Packages">
<!-- JWT token creation for API integration tests -->
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<!-- Test Frameworks -->
<PackageVersion Include="MSTest" Version="3.1.1" />
<PackageVersion Include="MSTest.TestAdapter" Version="3.1.1" />
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/AdaptiveRemote.App/AppHostBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BackendSettings>(configuration.GetSection(SettingsKeys.Backend))
.AddSingleton<ICognitoTokenService, CognitoTokenService>();
}
5 changes: 5 additions & 0 deletions src/AdaptiveRemote.App/Configuration/SettingsKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@ internal class SettingsKeys
/// Configuration section for <see cref="Services.Broadlink.IRDataSettings"/> IR command payloads.
/// </summary>
public const string IRData = "irdata";

/// <summary>
/// Configuration section for <see cref="Services.Backend.BackendSettings"/> backend service settings.
/// </summary>
public const string Backend = "backend";
}
11 changes: 11 additions & 0 deletions src/AdaptiveRemote.App/Logging/MessageLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
52 changes: 52 additions & 0 deletions src/AdaptiveRemote.App/Services/Backend/BackendSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
namespace AdaptiveRemote.Services.Backend;

/// <summary>
/// Configuration for the AdaptiveRemote backend services.
/// Maps to the "backend" section in appsettings.json.
/// </summary>
public class BackendSettings
{
/// <summary>
/// Base URL of the backend API (e.g. https://api.adaptiveremote.example.com for
/// production, or http://localhost:8080 for local development).
/// </summary>
public string BaseUrl { get; set; } = string.Empty;

/// <summary>
/// Cognito OAuth2 client credentials for the client application.
/// </summary>
public CognitoClientSettings Cognito { get; set; } = new();
}

/// <summary>
/// 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.
/// </summary>
public class CognitoClientSettings
{
/// <summary>
/// The Cognito user pool authority URL, e.g.
/// https://cognito-idp.{region}.amazonaws.com/{userPoolId}
/// Used to discover the token endpoint via OIDC configuration.
/// </summary>
public string Authority { get; set; } = string.Empty;

/// <summary>
/// The OAuth2 client ID registered in the Cognito user pool for the client application.
/// </summary>
public string ClientId { get; set; } = string.Empty;

/// <summary>
/// The OAuth2 client secret. Store in user secrets or environment variables —
/// never commit to source control.
/// </summary>
public string ClientSecret { get; set; } = string.Empty;

/// <summary>
/// OAuth2 scope(s) to request, space-separated (e.g. "adaptiveremote/layouts.read").
/// Leave empty to omit the scope parameter.
/// </summary>
public string? Scope { get; set; }
}
133 changes: 133 additions & 0 deletions src/AdaptiveRemote.App/Services/Backend/CognitoTokenService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System.Text.Json;
using AdaptiveRemote.Logging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace AdaptiveRemote.Services.Backend;

/// <summary>
/// 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 <see cref="ExpiryBuffer"/> of expiring,
/// at which point a new token is acquired.
/// </summary>
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<BackendSettings> settings,
ILogger<CognitoTokenService> logger)
{
_settings = settings.Value;
_httpClient = new HttpClient();
_log = new MessageLogger(logger);
}

public async Task<string> 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<string> 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<KeyValuePair<string, string>> 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();
}
}
15 changes: 15 additions & 0 deletions src/AdaptiveRemote.App/Services/Backend/ICognitoTokenService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace AdaptiveRemote.Services.Backend;

/// <summary>
/// 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.
/// </summary>
public interface ICognitoTokenService
{
/// <summary>
/// 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.
/// </summary>
Task<string> GetAccessTokenAsync(CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
<RootNamespace>AdaptiveRemote.Backend.CompiledLayoutService</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AdaptiveRemote.Contracts\AdaptiveRemote.Contracts.csproj" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace AdaptiveRemote.Backend.CompiledLayoutService.Configuration;

/// <summary>
/// Configuration for AWS Cognito JWT validation.
/// Maps to the "Cognito" section in appsettings.json.
/// </summary>
public class CognitoSettings
{
/// <summary>
/// The Cognito user pool authority URL, e.g.
/// https://cognito-idp.{region}.amazonaws.com/{userPoolId}
/// </summary>
public string Authority { get; set; } = string.Empty;

/// <summary>
/// The OAuth2 audience (app client ID). If empty, audience validation is skipped.
/// </summary>
public string? Audience { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Claims;
using AdaptiveRemote.Backend.CompiledLayoutService.Logging;
using AdaptiveRemote.Contracts;

Expand All @@ -9,18 +10,26 @@ public static void MapLayoutEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/layouts/compiled/active", GetActiveLayout)
.WithName(nameof(GetActiveLayout))
.Produces<CompiledLayout>(StatusCodes.Status200OK);
.Produces<CompiledLayout>(StatusCodes.Status200OK)
.RequireAuthorization();
}

private static async Task<IResult> GetActiveLayout(
ClaimsPrincipal user,
ILogger<Program> 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)
Expand All @@ -30,7 +39,6 @@ private static async Task<IResult> GetActiveLayout(

logger.ReturningActiveLayout(layout.Id);

// Use the LayoutContractsJsonContext for serialization
return Results.Json(
layout,
LayoutContractsJsonContext.Default.CompiledLayout);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading