Skip to content

Commit 327624c

Browse files
committed
[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
1 parent 8ea5dd8 commit 327624c

27 files changed

Lines changed: 851 additions & 34 deletions

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
<PackageVersion Include="Microsoft.VisualStudio.Threading" Version="17.14.15" />
3131
</ItemGroup>
3232
<ItemGroup Label="Test-Only Packages">
33+
<!-- JWT token creation for API integration tests -->
34+
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
3335
<!-- Test Frameworks -->
3436
<PackageVersion Include="MSTest" Version="3.1.1" />
3537
<PackageVersion Include="MSTest.TestAdapter" Version="3.1.1" />

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ services:
1010
environment:
1111
- ASPNETCORE_ENVIRONMENT=Development
1212
- ASPNETCORE_URLS=http://+:8080
13+
# Set Cognito dev user pool values here or via a .env file.
14+
# See _doc_Auth.md for Cognito dev user pool setup instructions.
15+
- Cognito__Authority=${COGNITO_AUTHORITY:-}
16+
- Cognito__Audience=${COGNITO_AUDIENCE:-}
1317
networks:
1418
- backend
1519

src/AdaptiveRemote.App/AppHostBuilderExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public static IHostBuilder ConfigureApp(this IHostBuilder hostBuilder)
1515
.AddTiVoSupport()
1616
.AddConversationSystem()
1717
.AddSystemWrapperServices()
18+
.AddBackendSupport()
1819
.OptionallyAddTestHookEndpoint();
1920

2021
public static IHostBuilder ConfigureAppSettings(this IHostBuilder hostBuilder, string[] args)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using AdaptiveRemote.Services.Backend;
2+
using Microsoft.Extensions.Configuration;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Hosting;
5+
6+
namespace AdaptiveRemote.Configuration;
7+
8+
internal static class BackendHostBuilderExtensions
9+
{
10+
public static IHostBuilder AddBackendSupport(this IHostBuilder builder)
11+
=> builder.ConfigureServices((context, services) =>
12+
services.AddBackendServices(context.Configuration));
13+
14+
private static IServiceCollection AddBackendServices(
15+
this IServiceCollection services,
16+
IConfiguration configuration)
17+
=> services
18+
.Configure<BackendSettings>(configuration.GetSection(SettingsKeys.Backend))
19+
.AddSingleton<ICognitoTokenService, CognitoTokenService>();
20+
}

src/AdaptiveRemote.App/Configuration/SettingsKeys.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,9 @@ internal class SettingsKeys
4141
/// Configuration section for <see cref="Services.Broadlink.IRDataSettings"/> IR command payloads.
4242
/// </summary>
4343
public const string IRData = "irdata";
44+
45+
/// <summary>
46+
/// Configuration section for <see cref="Services.Backend.BackendSettings"/> backend service settings.
47+
/// </summary>
48+
public const string Backend = "backend";
4449
}

src/AdaptiveRemote.App/Logging/MessageLogger.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,15 @@ public MessageLogger(ILogger logger)
346346

347347
[LoggerMessage(EventId = 1516, Level = LogLevel.Information, Message = "Registering test service {ServiceName} implementing {ContractType} in DI container")]
348348
public partial void TestEndpointHooksService_RegisteringTestServiceInDI(string serviceName, string contractType);
349+
350+
// 1600–1699: CognitoTokenService
351+
352+
[LoggerMessage(EventId = 1600, Level = LogLevel.Information, Message = "Acquiring Cognito access token via Client Credentials flow")]
353+
public partial void CognitoTokenService_AcquiringToken();
354+
355+
[LoggerMessage(EventId = 1601, Level = LogLevel.Information, Message = "Cognito access token acquired successfully")]
356+
public partial void CognitoTokenService_TokenAcquired();
357+
358+
[LoggerMessage(EventId = 1602, Level = LogLevel.Error, Message = "Failed to acquire Cognito access token")]
359+
public partial void CognitoTokenService_AcquireTokenFailed(Exception exception);
349360
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
namespace AdaptiveRemote.Services.Backend;
2+
3+
/// <summary>
4+
/// Configuration for the AdaptiveRemote backend services.
5+
/// Maps to the "backend" section in appsettings.json.
6+
/// </summary>
7+
public class BackendSettings
8+
{
9+
/// <summary>
10+
/// Base URL of the backend API (e.g. https://api.adaptiveremote.example.com for
11+
/// production, or http://localhost:8080 for local development).
12+
/// </summary>
13+
public string BaseUrl { get; set; } = string.Empty;
14+
15+
/// <summary>
16+
/// Cognito OAuth2 client credentials for the client application.
17+
/// </summary>
18+
public CognitoClientSettings Cognito { get; set; } = new();
19+
}
20+
21+
/// <summary>
22+
/// AWS Cognito Client Credentials flow settings for the client application.
23+
/// The client application authenticates as a machine client — no interactive login occurs.
24+
/// Sensitive values (ClientSecret) should be stored in user secrets or environment
25+
/// variables, not checked in to source control.
26+
/// </summary>
27+
public class CognitoClientSettings
28+
{
29+
/// <summary>
30+
/// The Cognito user pool authority URL, e.g.
31+
/// https://cognito-idp.{region}.amazonaws.com/{userPoolId}
32+
/// Used to discover the token endpoint via OIDC configuration.
33+
/// </summary>
34+
public string Authority { get; set; } = string.Empty;
35+
36+
/// <summary>
37+
/// The OAuth2 client ID registered in the Cognito user pool for the client application.
38+
/// </summary>
39+
public string ClientId { get; set; } = string.Empty;
40+
41+
/// <summary>
42+
/// The OAuth2 client secret. Store in user secrets or environment variables —
43+
/// never commit to source control.
44+
/// </summary>
45+
public string ClientSecret { get; set; } = string.Empty;
46+
47+
/// <summary>
48+
/// OAuth2 scope(s) to request, space-separated (e.g. "adaptiveremote/layouts.read").
49+
/// Leave empty to omit the scope parameter.
50+
/// </summary>
51+
public string? Scope { get; set; }
52+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using System.Net.Http;
2+
using System.Text.Json;
3+
using AdaptiveRemote.Logging;
4+
using Microsoft.Extensions.Logging;
5+
using Microsoft.Extensions.Options;
6+
7+
namespace AdaptiveRemote.Services.Backend;
8+
9+
/// <summary>
10+
/// Acquires and caches OAuth2 access tokens from AWS Cognito using the
11+
/// Client Credentials flow. Token refresh is lazy: the cached token is
12+
/// returned until it is within <see cref="ExpiryBuffer"/> of expiring,
13+
/// at which point a new token is acquired.
14+
/// </summary>
15+
internal sealed class CognitoTokenService : ICognitoTokenService, IDisposable
16+
{
17+
// Refresh the token this many seconds before it actually expires.
18+
private static readonly TimeSpan ExpiryBuffer = TimeSpan.FromSeconds(60);
19+
20+
private readonly BackendSettings _settings;
21+
private readonly HttpClient _httpClient;
22+
private readonly MessageLogger _log;
23+
24+
private string? _cachedToken;
25+
private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue;
26+
private string? _tokenEndpoint;
27+
private readonly SemaphoreSlim _lock = new(1, 1);
28+
29+
public CognitoTokenService(
30+
IOptions<BackendSettings> settings,
31+
ILogger<CognitoTokenService> logger)
32+
{
33+
_settings = settings.Value;
34+
_httpClient = new HttpClient();
35+
_log = new MessageLogger(logger);
36+
}
37+
38+
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
39+
{
40+
await _lock.WaitAsync(cancellationToken);
41+
try
42+
{
43+
if (_cachedToken is not null && DateTimeOffset.UtcNow < _tokenExpiry - ExpiryBuffer)
44+
{
45+
return _cachedToken;
46+
}
47+
48+
_log.CognitoTokenService_AcquiringToken();
49+
try
50+
{
51+
string endpoint = await GetTokenEndpointAsync(cancellationToken);
52+
(_cachedToken, _tokenExpiry) = await AcquireTokenAsync(endpoint, cancellationToken);
53+
_log.CognitoTokenService_TokenAcquired();
54+
return _cachedToken;
55+
}
56+
catch (Exception ex)
57+
{
58+
_log.CognitoTokenService_AcquireTokenFailed(ex);
59+
throw;
60+
}
61+
}
62+
finally
63+
{
64+
_lock.Release();
65+
}
66+
}
67+
68+
private async Task<string> GetTokenEndpointAsync(CancellationToken cancellationToken)
69+
{
70+
if (_tokenEndpoint is not null)
71+
{
72+
return _tokenEndpoint;
73+
}
74+
75+
CognitoClientSettings cognito = _settings.Cognito;
76+
string discoveryUrl = $"{cognito.Authority.TrimEnd('/')}/.well-known/openid-configuration";
77+
78+
using HttpResponseMessage discoveryResponse =
79+
await _httpClient.GetAsync(discoveryUrl, cancellationToken);
80+
81+
discoveryResponse.EnsureSuccessStatusCode();
82+
83+
string json = await discoveryResponse.Content.ReadAsStringAsync(cancellationToken);
84+
using JsonDocument doc = JsonDocument.Parse(json);
85+
_tokenEndpoint = doc.RootElement.GetProperty("token_endpoint").GetString()
86+
?? throw new InvalidOperationException(
87+
"token_endpoint not found in OIDC discovery document");
88+
89+
return _tokenEndpoint;
90+
}
91+
92+
private async Task<(string Token, DateTimeOffset Expiry)> AcquireTokenAsync(
93+
string endpoint,
94+
CancellationToken cancellationToken)
95+
{
96+
CognitoClientSettings cognito = _settings.Cognito;
97+
98+
List<KeyValuePair<string, string>> parameters =
99+
[
100+
new("grant_type", "client_credentials"),
101+
new("client_id", cognito.ClientId),
102+
new("client_secret", cognito.ClientSecret),
103+
];
104+
105+
if (!string.IsNullOrEmpty(cognito.Scope))
106+
{
107+
parameters.Add(new("scope", cognito.Scope));
108+
}
109+
110+
using FormUrlEncodedContent content = new(parameters);
111+
using HttpResponseMessage response =
112+
await _httpClient.PostAsync(endpoint, content, cancellationToken);
113+
114+
response.EnsureSuccessStatusCode();
115+
116+
string json = await response.Content.ReadAsStringAsync(cancellationToken);
117+
using JsonDocument doc = JsonDocument.Parse(json);
118+
119+
string accessToken = doc.RootElement.GetProperty("access_token").GetString()
120+
?? throw new InvalidOperationException("access_token not found in token response");
121+
122+
int expiresIn = doc.RootElement.TryGetProperty("expires_in", out JsonElement expiresInElement)
123+
? expiresInElement.GetInt32()
124+
: 3600;
125+
126+
return (accessToken, DateTimeOffset.UtcNow.AddSeconds(expiresIn));
127+
}
128+
129+
public void Dispose()
130+
{
131+
_httpClient.Dispose();
132+
_lock.Dispose();
133+
}
134+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace AdaptiveRemote.Services.Backend;
2+
3+
/// <summary>
4+
/// Acquires and caches OAuth2 access tokens from AWS Cognito using the
5+
/// Client Credentials flow. The client application calls this to obtain a
6+
/// bearer token before making requests to backend services.
7+
/// </summary>
8+
public interface ICognitoTokenService
9+
{
10+
/// <summary>
11+
/// Returns a valid access token, acquiring or refreshing it from Cognito
12+
/// as needed. The returned token is safe to use as a Bearer token immediately.
13+
/// </summary>
14+
Task<string> GetAccessTokenAsync(CancellationToken cancellationToken);
15+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace AdaptiveRemote.Backend.CompiledLayoutService.Configuration;
2+
3+
/// <summary>
4+
/// Configuration for AWS Cognito JWT validation.
5+
/// Maps to the "Cognito" section in appsettings.json.
6+
/// </summary>
7+
public class CognitoSettings
8+
{
9+
/// <summary>
10+
/// The Cognito user pool authority URL, e.g.
11+
/// https://cognito-idp.{region}.amazonaws.com/{userPoolId}
12+
/// </summary>
13+
public string Authority { get; set; } = string.Empty;
14+
15+
/// <summary>
16+
/// The OAuth2 audience (app client ID). If empty, audience validation is skipped.
17+
/// </summary>
18+
public string? Audience { get; set; }
19+
}

0 commit comments

Comments
 (0)