- Add JWT bearer auth, session endpoint, and auth audit capture - Add server-side Keycloak token exchange and refresh endpoints - Load Keycloak client secret from ignored .env file - Configure Canopy Keycloak realm, CORS, and Vite API proxy - Add frontend protected auth callback, token storage, and refresh handling - Cover backend and frontend auth behavior with testspull/15/head
| @@ -17,3 +17,5 @@ coverage/ | |||||
| *.DS_Store | *.DS_Store | ||||
| Thumbs.db | Thumbs.db | ||||
| Campaign_Tracker.Server/Campaign_Tracker.Server.csproj | Campaign_Tracker.Server/Campaign_Tracker.Server.csproj | ||||
| .env | |||||
| Campaign_Tracker.Server/appsettings.Development.json | |||||
| @@ -0,0 +1,107 @@ | |||||
| using System.IdentityModel.Tokens.Jwt; | |||||
| using System.Net; | |||||
| using System.Net.Http.Json; | |||||
| using System.Net.Http.Headers; | |||||
| using System.Security.Claims; | |||||
| using System.Text; | |||||
| using Campaign_Tracker.Server.Authentication; | |||||
| using Microsoft.AspNetCore.Mvc.Testing; | |||||
| using Microsoft.Extensions.DependencyInjection; | |||||
| using Microsoft.IdentityModel.Tokens; | |||||
| namespace Campaign_Tracker.Server.Tests; | |||||
| public class AuthEndpointTests : IClassFixture<WebApplicationFactory<Program>> | |||||
| { | |||||
| private const string Issuer = "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI"; | |||||
| private const string ClientId = "canopy-web"; | |||||
| private const string SigningKey = "test-signing-key-with-at-least-32-characters"; | |||||
| private readonly WebApplicationFactory<Program> _factory; | |||||
| public AuthEndpointTests(WebApplicationFactory<Program> factory) | |||||
| { | |||||
| Environment.SetEnvironmentVariable("Keycloak__Authority", Issuer); | |||||
| Environment.SetEnvironmentVariable("Keycloak__ValidIssuer", Issuer); | |||||
| Environment.SetEnvironmentVariable("Keycloak__PublicAuthority", Issuer); | |||||
| Environment.SetEnvironmentVariable("Keycloak__ClientId", ClientId); | |||||
| Environment.SetEnvironmentVariable("Keycloak__DisableHttpsMetadata", "true"); | |||||
| Environment.SetEnvironmentVariable("Keycloak__TestSigningKey", SigningKey); | |||||
| _factory = factory; | |||||
| } | |||||
| [Fact] | |||||
| public async Task SessionEndpoint_WithoutToken_ReturnsUnauthorized() | |||||
| { | |||||
| using var client = _factory.CreateClient(); | |||||
| var response = await client.GetAsync("/api/auth/session"); | |||||
| Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); | |||||
| } | |||||
| [Fact] | |||||
| public async Task SessionEndpoint_WithValidToken_ReturnsRoleWorkspaceAndAuditsSuccess() | |||||
| { | |||||
| using var client = _factory.CreateClient(); | |||||
| client.DefaultRequestHeaders.Authorization = | |||||
| new AuthenticationHeaderValue("Bearer", CreateToken("daniel@example.test", "client-services")); | |||||
| var httpResponse = await client.GetAsync("/api/auth/session"); | |||||
| var auditStore = _factory.Services.GetRequiredService<IAuthenticationAuditStore>(); | |||||
| Assert.True(httpResponse.IsSuccessStatusCode, string.Join("; ", auditStore.Events.Select(audit => audit.Reason))); | |||||
| var response = await httpResponse.Content.ReadFromJsonAsync<AuthSessionResponse>(); | |||||
| Assert.NotNull(response); | |||||
| Assert.Equal("daniel@example.test", response.UserName); | |||||
| Assert.Contains("client-services", response.Roles); | |||||
| Assert.Equal("/workspace/client-services", response.WorkspacePath); | |||||
| Assert.Contains(auditStore.Events, audit => | |||||
| audit.EventType == AuthenticationAuditEventType.Success && | |||||
| audit.Subject == "daniel@example.test"); | |||||
| } | |||||
| [Fact] | |||||
| public async Task SessionEndpoint_WithInvalidToken_ReturnsUnauthorizedAndAuditsFailure() | |||||
| { | |||||
| using var client = _factory.CreateClient(); | |||||
| client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid.jwt.value"); | |||||
| var response = await client.GetAsync("/api/auth/session"); | |||||
| var auditStore = _factory.Services.GetRequiredService<IAuthenticationAuditStore>(); | |||||
| Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); | |||||
| Assert.Contains(auditStore.Events, audit => | |||||
| audit.EventType == AuthenticationAuditEventType.Failure && | |||||
| audit.Reason.Contains("invalid", StringComparison.OrdinalIgnoreCase)); | |||||
| } | |||||
| private static string CreateToken(string subject, string role) | |||||
| { | |||||
| var credentials = new SigningCredentials( | |||||
| new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SigningKey)), | |||||
| SecurityAlgorithms.HmacSha256); | |||||
| var token = new JwtSecurityToken( | |||||
| issuer: Issuer, | |||||
| audience: ClientId, | |||||
| claims: | |||||
| [ | |||||
| new Claim(JwtRegisteredClaimNames.Sub, subject), | |||||
| new Claim(ClaimTypes.Name, subject), | |||||
| new Claim(ClaimTypes.Role, role), | |||||
| ], | |||||
| expires: DateTime.UtcNow.AddMinutes(10), | |||||
| signingCredentials: credentials); | |||||
| return new JwtSecurityTokenHandler().WriteToken(token); | |||||
| } | |||||
| private sealed class AuthSessionResponse | |||||
| { | |||||
| public string UserName { get; init; } = string.Empty; | |||||
| public string[] Roles { get; init; } = []; | |||||
| public string WorkspacePath { get; init; } = string.Empty; | |||||
| } | |||||
| } | |||||
| @@ -11,6 +11,7 @@ | |||||
| <PackageReference Include="coverlet.collector" Version="6.0.4" /> | <PackageReference Include="coverlet.collector" Version="6.0.4" /> | ||||
| <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" /> | <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" /> | ||||
| <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | ||||
| <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" /> | |||||
| <PackageReference Include="xunit" Version="2.9.3" /> | <PackageReference Include="xunit" Version="2.9.3" /> | ||||
| <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> | <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> | ||||
| </ItemGroup> | </ItemGroup> | ||||
| @@ -0,0 +1,42 @@ | |||||
| using Campaign_Tracker.Server.Configuration; | |||||
| using Microsoft.Extensions.Configuration; | |||||
| namespace Campaign_Tracker.Server.Tests; | |||||
| public sealed class DotEnvConfigurationTests : IDisposable | |||||
| { | |||||
| private readonly string _rootPath; | |||||
| private readonly string _serverPath; | |||||
| public DotEnvConfigurationTests() | |||||
| { | |||||
| _rootPath = Path.Combine(Path.GetTempPath(), $"campaign-tracker-env-{Guid.NewGuid():N}"); | |||||
| _serverPath = Path.Combine(_rootPath, "Campaign_Tracker.Server"); | |||||
| Directory.CreateDirectory(_serverPath); | |||||
| } | |||||
| [Fact] | |||||
| public void Load_OverridesAppsettingsPlaceholderWithRootEnvValue() | |||||
| { | |||||
| File.WriteAllText( | |||||
| Path.Combine(_rootPath, ".env"), | |||||
| "Keycloak__ClientSecret=secret-from-env"); | |||||
| var configuration = new ConfigurationManager(); | |||||
| configuration.AddInMemoryCollection(new Dictionary<string, string?> | |||||
| { | |||||
| ["Keycloak:ClientSecret"] = "REPLACE-ON-SERVER", | |||||
| }); | |||||
| DotEnvConfiguration.Load(configuration, _serverPath); | |||||
| Assert.Equal("secret-from-env", configuration["Keycloak:ClientSecret"]); | |||||
| } | |||||
| public void Dispose() | |||||
| { | |||||
| if (Directory.Exists(_rootPath)) | |||||
| { | |||||
| Directory.Delete(_rootPath, recursive: true); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,63 @@ | |||||
| using System.Net; | |||||
| using System.Text.Json; | |||||
| using Campaign_Tracker.Server.Authentication; | |||||
| using Microsoft.Extensions.Logging.Abstractions; | |||||
| using Microsoft.Extensions.Options; | |||||
| namespace Campaign_Tracker.Server.Tests; | |||||
| public sealed class KeycloakTokenClientTests | |||||
| { | |||||
| [Fact] | |||||
| public async Task ExchangeAuthorizationCodeAsync_PostsClientSecretToKeycloakTokenEndpoint() | |||||
| { | |||||
| using var handler = new CapturingMessageHandler(); | |||||
| using var httpClient = new HttpClient(handler); | |||||
| var client = new KeycloakTokenClient( | |||||
| httpClient, | |||||
| Options.Create(new KeycloakOptions | |||||
| { | |||||
| Authority = "http://localhost:8180/realms/KCI", | |||||
| PublicAuthority = "http://localhost:8180/realms/KCI", | |||||
| ClientId = "canopy-web", | |||||
| ClientSecret = "secret-from-env", | |||||
| }), | |||||
| NullLogger<KeycloakTokenClient>.Instance); | |||||
| var tokens = await client.ExchangeAuthorizationCodeAsync( | |||||
| "auth-code", | |||||
| "http://kci-app01.ntp.kentcommunications.com/auth/callback", | |||||
| CancellationToken.None); | |||||
| Assert.Equal("http://localhost:8180/realms/KCI/protocol/openid-connect/token", handler.RequestUri); | |||||
| Assert.Equal("access-token", tokens.AccessToken); | |||||
| Assert.Contains("client_id=canopy-web", handler.FormBody); | |||||
| Assert.Contains("client_secret=secret-from-env", handler.FormBody); | |||||
| Assert.Contains("code=auth-code", handler.FormBody); | |||||
| Assert.Contains("grant_type=authorization_code", handler.FormBody); | |||||
| } | |||||
| private sealed class CapturingMessageHandler : HttpMessageHandler, IDisposable | |||||
| { | |||||
| public string RequestUri { get; private set; } = string.Empty; | |||||
| public string FormBody { get; private set; } = string.Empty; | |||||
| protected override async Task<HttpResponseMessage> SendAsync( | |||||
| HttpRequestMessage request, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| RequestUri = request.RequestUri?.ToString() ?? string.Empty; | |||||
| FormBody = await request.Content!.ReadAsStringAsync(cancellationToken); | |||||
| return new HttpResponseMessage(HttpStatusCode.OK) | |||||
| { | |||||
| Content = new StringContent(JsonSerializer.Serialize(new | |||||
| { | |||||
| access_token = "access-token", | |||||
| refresh_token = "refresh-token", | |||||
| expires_in = 300, | |||||
| })), | |||||
| }; | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| namespace Campaign_Tracker.Server.Authentication; | |||||
| public enum AuthenticationAuditEventType | |||||
| { | |||||
| Success, | |||||
| Failure, | |||||
| } | |||||
| public sealed record AuthenticationAuditEvent( | |||||
| AuthenticationAuditEventType EventType, | |||||
| string Subject, | |||||
| string Reason, | |||||
| string TraceIdentifier, | |||||
| DateTimeOffset RecordedAt); | |||||
| @@ -0,0 +1,10 @@ | |||||
| namespace Campaign_Tracker.Server.Authentication; | |||||
| public interface IAuthenticationAuditStore | |||||
| { | |||||
| IReadOnlyCollection<AuthenticationAuditEvent> Events { get; } | |||||
| void RecordSuccess(string subject, string traceIdentifier); | |||||
| void RecordFailure(string reason, string traceIdentifier); | |||||
| } | |||||
| @@ -0,0 +1,30 @@ | |||||
| using System.Collections.Concurrent; | |||||
| namespace Campaign_Tracker.Server.Authentication; | |||||
| public sealed class InMemoryAuthenticationAuditStore : IAuthenticationAuditStore | |||||
| { | |||||
| private readonly ConcurrentQueue<AuthenticationAuditEvent> _events = new(); | |||||
| public IReadOnlyCollection<AuthenticationAuditEvent> Events => _events.ToArray(); | |||||
| public void RecordSuccess(string subject, string traceIdentifier) | |||||
| { | |||||
| _events.Enqueue(new AuthenticationAuditEvent( | |||||
| AuthenticationAuditEventType.Success, | |||||
| subject, | |||||
| "authenticated", | |||||
| traceIdentifier, | |||||
| DateTimeOffset.UtcNow)); | |||||
| } | |||||
| public void RecordFailure(string reason, string traceIdentifier) | |||||
| { | |||||
| _events.Enqueue(new AuthenticationAuditEvent( | |||||
| AuthenticationAuditEventType.Failure, | |||||
| "anonymous", | |||||
| reason, | |||||
| traceIdentifier, | |||||
| DateTimeOffset.UtcNow)); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| namespace Campaign_Tracker.Server.Authentication; | |||||
| public sealed class KeycloakOptions | |||||
| { | |||||
| public const string SectionName = "Keycloak"; | |||||
| public string Authority { get; init; } = "http://localhost:8180/realms/KCI"; | |||||
| public string? MetadataAddress { get; init; } | |||||
| public string? ValidIssuer { get; init; } | |||||
| public string PublicAuthority { get; init; } = "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI"; | |||||
| public string ClientId { get; init; } = "canopy-web"; | |||||
| public string? ClientSecret { get; init; } | |||||
| public string? Audience { get; init; } | |||||
| public bool DisableHttpsMetadata { get; init; } = true; | |||||
| public string? TestSigningKey { get; init; } | |||||
| public string TokenAudience => string.IsNullOrWhiteSpace(Audience) ? ClientId : Audience; | |||||
| public string TokenIssuer => | |||||
| string.IsNullOrWhiteSpace(ValidIssuer) ? PublicAuthority : ValidIssuer; | |||||
| public string TokenEndpointAuthority => | |||||
| string.IsNullOrWhiteSpace(PublicAuthority) ? Authority : PublicAuthority; | |||||
| } | |||||
| @@ -0,0 +1,137 @@ | |||||
| using System.Net; | |||||
| using System.Text.Json.Serialization; | |||||
| using Microsoft.Extensions.Options; | |||||
| namespace Campaign_Tracker.Server.Authentication; | |||||
| public interface IKeycloakTokenClient | |||||
| { | |||||
| Task<AuthTokenSetResponse> ExchangeAuthorizationCodeAsync( | |||||
| string code, | |||||
| string redirectUri, | |||||
| CancellationToken cancellationToken); | |||||
| Task<AuthTokenSetResponse> RefreshAccessTokenAsync( | |||||
| string refreshToken, | |||||
| CancellationToken cancellationToken); | |||||
| } | |||||
| public sealed class KeycloakTokenClient : IKeycloakTokenClient | |||||
| { | |||||
| private readonly HttpClient _httpClient; | |||||
| private readonly ILogger<KeycloakTokenClient> _logger; | |||||
| private readonly KeycloakOptions _options; | |||||
| public KeycloakTokenClient( | |||||
| HttpClient httpClient, | |||||
| IOptions<KeycloakOptions> options, | |||||
| ILogger<KeycloakTokenClient> logger) | |||||
| { | |||||
| _httpClient = httpClient; | |||||
| _logger = logger; | |||||
| _options = options.Value; | |||||
| } | |||||
| public Task<AuthTokenSetResponse> ExchangeAuthorizationCodeAsync( | |||||
| string code, | |||||
| string redirectUri, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| return RequestTokenSetAsync( | |||||
| new Dictionary<string, string> | |||||
| { | |||||
| ["grant_type"] = "authorization_code", | |||||
| ["client_id"] = _options.ClientId, | |||||
| ["client_secret"] = GetClientSecret(), | |||||
| ["redirect_uri"] = redirectUri, | |||||
| ["code"] = code, | |||||
| }, | |||||
| cancellationToken); | |||||
| } | |||||
| public Task<AuthTokenSetResponse> RefreshAccessTokenAsync( | |||||
| string refreshToken, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| return RequestTokenSetAsync( | |||||
| new Dictionary<string, string> | |||||
| { | |||||
| ["grant_type"] = "refresh_token", | |||||
| ["client_id"] = _options.ClientId, | |||||
| ["client_secret"] = GetClientSecret(), | |||||
| ["refresh_token"] = refreshToken, | |||||
| }, | |||||
| cancellationToken); | |||||
| } | |||||
| private async Task<AuthTokenSetResponse> RequestTokenSetAsync( | |||||
| Dictionary<string, string> formValues, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| using var content = new FormUrlEncodedContent(formValues); | |||||
| using var response = await _httpClient.PostAsync(TokenEndpoint, content, cancellationToken); | |||||
| if (!response.IsSuccessStatusCode) | |||||
| { | |||||
| var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); | |||||
| _logger.LogWarning( | |||||
| "Keycloak token request failed with {StatusCode}: {ErrorBody}", | |||||
| (int)response.StatusCode, | |||||
| errorBody); | |||||
| throw new KeycloakTokenRequestException(response.StatusCode, errorBody); | |||||
| } | |||||
| var payload = await response.Content | |||||
| .ReadFromJsonAsync<KeycloakTokenResponse>(cancellationToken) | |||||
| ?? throw new InvalidOperationException("Keycloak token response was empty."); | |||||
| return new AuthTokenSetResponse( | |||||
| payload.AccessToken, | |||||
| payload.RefreshToken, | |||||
| DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn).ToUnixTimeSeconds()); | |||||
| } | |||||
| private string TokenEndpoint => | |||||
| $"{_options.TokenEndpointAuthority.TrimEnd('/')}/protocol/openid-connect/token"; | |||||
| private string GetClientSecret() | |||||
| { | |||||
| if (string.IsNullOrWhiteSpace(_options.ClientSecret)) | |||||
| { | |||||
| throw new InvalidOperationException("Keycloak client secret is not configured."); | |||||
| } | |||||
| return _options.ClientSecret; | |||||
| } | |||||
| } | |||||
| public sealed class KeycloakTokenRequestException : Exception | |||||
| { | |||||
| public KeycloakTokenRequestException(HttpStatusCode statusCode, string responseBody) | |||||
| : base($"Keycloak token request failed with status {(int)statusCode}.") | |||||
| { | |||||
| StatusCode = statusCode; | |||||
| ResponseBody = responseBody; | |||||
| } | |||||
| public HttpStatusCode StatusCode { get; } | |||||
| public string ResponseBody { get; } | |||||
| } | |||||
| public sealed record AuthTokenSetResponse( | |||||
| string AccessToken, | |||||
| string RefreshToken, | |||||
| long ExpiresAtEpochSeconds); | |||||
| internal sealed class KeycloakTokenResponse | |||||
| { | |||||
| [JsonPropertyName("access_token")] | |||||
| public string AccessToken { get; init; } = string.Empty; | |||||
| [JsonPropertyName("refresh_token")] | |||||
| public string RefreshToken { get; init; } = string.Empty; | |||||
| [JsonPropertyName("expires_in")] | |||||
| public int ExpiresIn { get; init; } | |||||
| } | |||||
| @@ -0,0 +1,27 @@ | |||||
| namespace Campaign_Tracker.Server.Authentication; | |||||
| public static class RoleWorkspaceResolver | |||||
| { | |||||
| private static readonly IReadOnlyDictionary<string, string> RoleWorkspacePaths = | |||||
| new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) | |||||
| { | |||||
| ["client-services"] = "/workspace/client-services", | |||||
| ["production-lead"] = "/workspace/production", | |||||
| ["transportation"] = "/workspace/transportation", | |||||
| ["operations-admin"] = "/workspace/admin", | |||||
| ["support-analyst"] = "/workspace/support", | |||||
| }; | |||||
| public static string ResolveWorkspacePath(IEnumerable<string> roles) | |||||
| { | |||||
| foreach (var role in roles) | |||||
| { | |||||
| if (RoleWorkspacePaths.TryGetValue(role, out var workspacePath)) | |||||
| { | |||||
| return workspacePath; | |||||
| } | |||||
| } | |||||
| return "/workspace"; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,58 @@ | |||||
| namespace Campaign_Tracker.Server.Configuration; | |||||
| public static class DotEnvConfiguration | |||||
| { | |||||
| public static void Load(ConfigurationManager configuration, string contentRootPath) | |||||
| { | |||||
| var rootEnvPath = Path.GetFullPath(Path.Combine(contentRootPath, "..", ".env")); | |||||
| var serverEnvPath = Path.Combine(contentRootPath, ".env"); | |||||
| var values = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase); | |||||
| AddFileValues(rootEnvPath, values); | |||||
| AddFileValues(serverEnvPath, values); | |||||
| if (values.Count > 0) | |||||
| { | |||||
| configuration.AddInMemoryCollection(values); | |||||
| } | |||||
| } | |||||
| private static void AddFileValues(string path, IDictionary<string, string?> values) | |||||
| { | |||||
| if (!File.Exists(path)) | |||||
| { | |||||
| return; | |||||
| } | |||||
| foreach (var line in File.ReadLines(path)) | |||||
| { | |||||
| var trimmed = line.Trim(); | |||||
| if (trimmed.Length == 0 || trimmed.StartsWith('#')) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| var separatorIndex = trimmed.IndexOf('='); | |||||
| if (separatorIndex <= 0) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| var key = trimmed[..separatorIndex].Trim().Replace("__", ":"); | |||||
| var value = trimmed[(separatorIndex + 1)..].Trim(); | |||||
| values[key] = Unquote(value); | |||||
| } | |||||
| } | |||||
| private static string Unquote(string value) | |||||
| { | |||||
| if (value.Length >= 2 && | |||||
| ((value.StartsWith('"') && value.EndsWith('"')) || | |||||
| (value.StartsWith('\'') && value.EndsWith('\'')))) | |||||
| { | |||||
| return value[1..^1]; | |||||
| } | |||||
| return value; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,34 @@ | |||||
| using System.Security.Claims; | |||||
| using Campaign_Tracker.Server.Authentication; | |||||
| using Microsoft.AspNetCore.Authorization; | |||||
| using Microsoft.AspNetCore.Mvc; | |||||
| namespace Campaign_Tracker.Server.Controllers; | |||||
| [ApiController] | |||||
| [Authorize] | |||||
| [Route("api/auth/session")] | |||||
| public sealed class AuthSessionController : ControllerBase | |||||
| { | |||||
| [HttpGet] | |||||
| public ActionResult<AuthSessionResponse> Get() | |||||
| { | |||||
| var roles = User.FindAll(ClaimTypes.Role) | |||||
| .Select(claim => claim.Value) | |||||
| .Distinct(StringComparer.OrdinalIgnoreCase) | |||||
| .ToArray(); | |||||
| var userName = User.Identity?.Name | |||||
| ?? User.FindFirstValue(ClaimTypes.NameIdentifier) | |||||
| ?? "unknown"; | |||||
| return Ok(new AuthSessionResponse( | |||||
| userName, | |||||
| roles, | |||||
| RoleWorkspaceResolver.ResolveWorkspacePath(roles))); | |||||
| } | |||||
| } | |||||
| public sealed record AuthSessionResponse( | |||||
| string UserName, | |||||
| string[] Roles, | |||||
| string WorkspacePath); | |||||
| @@ -0,0 +1,87 @@ | |||||
| using Campaign_Tracker.Server.Authentication; | |||||
| using Microsoft.AspNetCore.Authorization; | |||||
| using Microsoft.AspNetCore.Mvc; | |||||
| namespace Campaign_Tracker.Server.Controllers; | |||||
| [ApiController] | |||||
| [AllowAnonymous] | |||||
| [Route("api/auth/token")] | |||||
| public sealed class AuthTokenController : ControllerBase | |||||
| { | |||||
| private readonly IHostEnvironment _environment; | |||||
| private readonly IKeycloakTokenClient _tokenClient; | |||||
| public AuthTokenController( | |||||
| IKeycloakTokenClient tokenClient, | |||||
| IHostEnvironment environment) | |||||
| { | |||||
| _environment = environment; | |||||
| _tokenClient = tokenClient; | |||||
| } | |||||
| [HttpPost("exchange")] | |||||
| public async Task<ActionResult<AuthTokenSetResponse>> Exchange( | |||||
| [FromBody] AuthorizationCodeExchangeRequest request, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| if (string.IsNullOrWhiteSpace(request.Code) || | |||||
| string.IsNullOrWhiteSpace(request.RedirectUri)) | |||||
| { | |||||
| return BadRequest(); | |||||
| } | |||||
| try | |||||
| { | |||||
| return Ok(await _tokenClient.ExchangeAuthorizationCodeAsync( | |||||
| request.Code, | |||||
| request.RedirectUri, | |||||
| cancellationToken)); | |||||
| } | |||||
| catch (KeycloakTokenRequestException exception) | |||||
| { | |||||
| return Unauthorized(CreateTokenExchangeProblem(exception)); | |||||
| } | |||||
| } | |||||
| [HttpPost("refresh")] | |||||
| public async Task<ActionResult<AuthTokenSetResponse>> Refresh( | |||||
| [FromBody] RefreshTokenRequest request, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| if (string.IsNullOrWhiteSpace(request.RefreshToken)) | |||||
| { | |||||
| return BadRequest(); | |||||
| } | |||||
| try | |||||
| { | |||||
| return Ok(await _tokenClient.RefreshAccessTokenAsync( | |||||
| request.RefreshToken, | |||||
| cancellationToken)); | |||||
| } | |||||
| catch (KeycloakTokenRequestException exception) | |||||
| { | |||||
| return Unauthorized(CreateTokenExchangeProblem(exception)); | |||||
| } | |||||
| } | |||||
| private object CreateTokenExchangeProblem(KeycloakTokenRequestException exception) | |||||
| { | |||||
| if (!_environment.IsDevelopment()) | |||||
| { | |||||
| return new { error = "Keycloak token request failed." }; | |||||
| } | |||||
| return new | |||||
| { | |||||
| error = "Keycloak token request failed.", | |||||
| statusCode = (int)exception.StatusCode, | |||||
| keycloakResponse = exception.ResponseBody, | |||||
| }; | |||||
| } | |||||
| } | |||||
| public sealed record AuthorizationCodeExchangeRequest(string Code, string RedirectUri); | |||||
| public sealed record RefreshTokenRequest(string RefreshToken); | |||||
| @@ -1,10 +1,121 @@ | |||||
| using System.Security.Claims; | |||||
| using System.Text; | |||||
| using Campaign_Tracker.Server.Authentication; | |||||
| using Campaign_Tracker.Server.Configuration; | |||||
| using Microsoft.AspNetCore.Authentication.JwtBearer; | |||||
| using Microsoft.IdentityModel.Protocols.OpenIdConnect; | |||||
| using Microsoft.IdentityModel.Tokens; | |||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||
| DotEnvConfiguration.Load(builder.Configuration, builder.Environment.ContentRootPath); | |||||
| // Add services to the container. | // Add services to the container. | ||||
| builder.Services.AddControllers(); | builder.Services.AddControllers(); | ||||
| // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi | // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi | ||||
| builder.Services.AddOpenApi(); | builder.Services.AddOpenApi(); | ||||
| builder.Services.Configure<KeycloakOptions>(builder.Configuration.GetSection(KeycloakOptions.SectionName)); | |||||
| builder.Services.AddSingleton<IAuthenticationAuditStore, InMemoryAuthenticationAuditStore>(); | |||||
| builder.Services.AddHttpClient<IKeycloakTokenClient, KeycloakTokenClient>(); | |||||
| var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? []; | |||||
| builder.Services.AddCors(options => | |||||
| { | |||||
| options.AddPolicy("ConfiguredOrigins", policy => | |||||
| { | |||||
| if (allowedOrigins.Length > 0) | |||||
| { | |||||
| policy.WithOrigins(allowedOrigins) | |||||
| .AllowAnyHeader() | |||||
| .AllowAnyMethod(); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| var keycloakOptions = builder.Configuration | |||||
| .GetSection(KeycloakOptions.SectionName) | |||||
| .Get<KeycloakOptions>() ?? new KeycloakOptions(); | |||||
| builder.Services | |||||
| .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) | |||||
| .AddJwtBearer(options => | |||||
| { | |||||
| options.Audience = keycloakOptions.TokenAudience; | |||||
| options.RequireHttpsMetadata = !keycloakOptions.DisableHttpsMetadata; | |||||
| if (!string.IsNullOrWhiteSpace(keycloakOptions.MetadataAddress)) | |||||
| { | |||||
| options.MetadataAddress = keycloakOptions.MetadataAddress; | |||||
| } | |||||
| options.TokenValidationParameters = new TokenValidationParameters | |||||
| { | |||||
| ValidateIssuer = true, | |||||
| ValidIssuer = keycloakOptions.TokenIssuer, | |||||
| ValidateAudience = true, | |||||
| ValidAudience = keycloakOptions.TokenAudience, | |||||
| ValidateLifetime = true, | |||||
| NameClaimType = ClaimTypes.Name, | |||||
| RoleClaimType = ClaimTypes.Role, | |||||
| }; | |||||
| if (!string.IsNullOrWhiteSpace(keycloakOptions.TestSigningKey)) | |||||
| { | |||||
| var issuerSigningKey = | |||||
| new SymmetricSecurityKey(Encoding.UTF8.GetBytes(keycloakOptions.TestSigningKey)); | |||||
| options.TokenValidationParameters.ValidateIssuerSigningKey = true; | |||||
| options.TokenValidationParameters.IssuerSigningKey = issuerSigningKey; | |||||
| options.TokenValidationParameters.IssuerSigningKeys = [issuerSigningKey]; | |||||
| options.Configuration = new OpenIdConnectConfiguration | |||||
| { | |||||
| Issuer = keycloakOptions.TokenIssuer, | |||||
| }; | |||||
| options.Configuration.SigningKeys.Add(issuerSigningKey); | |||||
| } | |||||
| else | |||||
| { | |||||
| options.Authority = keycloakOptions.Authority; | |||||
| } | |||||
| options.Events = new JwtBearerEvents | |||||
| { | |||||
| OnTokenValidated = context => | |||||
| { | |||||
| var auditStore = context.HttpContext.RequestServices | |||||
| .GetRequiredService<IAuthenticationAuditStore>(); | |||||
| var subject = context.Principal?.Identity?.Name | |||||
| ?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier) | |||||
| ?? "unknown"; | |||||
| auditStore.RecordSuccess(subject, context.HttpContext.TraceIdentifier); | |||||
| return Task.CompletedTask; | |||||
| }, | |||||
| OnAuthenticationFailed = context => | |||||
| { | |||||
| var auditStore = context.HttpContext.RequestServices | |||||
| .GetRequiredService<IAuthenticationAuditStore>(); | |||||
| var reason = context.Exception is null | |||||
| ? "invalid bearer token" | |||||
| : $"invalid bearer token: {context.Exception.GetType().Name}"; | |||||
| auditStore.RecordFailure(reason, context.HttpContext.TraceIdentifier); | |||||
| return Task.CompletedTask; | |||||
| }, | |||||
| OnChallenge = context => | |||||
| { | |||||
| if (context.AuthenticateFailure is null && | |||||
| context.Request.Headers.ContainsKey("Authorization")) | |||||
| { | |||||
| var auditStore = context.HttpContext.RequestServices | |||||
| .GetRequiredService<IAuthenticationAuditStore>(); | |||||
| auditStore.RecordFailure("invalid authorization header", context.HttpContext.TraceIdentifier); | |||||
| } | |||||
| return Task.CompletedTask; | |||||
| }, | |||||
| }; | |||||
| }); | |||||
| builder.Services.AddAuthorization(); | |||||
| var app = builder.Build(); | var app = builder.Build(); | ||||
| @@ -16,6 +127,8 @@ if (app.Environment.IsDevelopment()) | |||||
| app.UseHttpsRedirection(); | app.UseHttpsRedirection(); | ||||
| app.UseCors("ConfiguredOrigins"); | |||||
| app.UseAuthentication(); | |||||
| app.UseAuthorization(); | app.UseAuthorization(); | ||||
| app.MapControllers(); | app.MapControllers(); | ||||
| @@ -4,5 +4,19 @@ | |||||
| "Default": "Information", | "Default": "Information", | ||||
| "Microsoft.AspNetCore": "Warning" | "Microsoft.AspNetCore": "Warning" | ||||
| } | } | ||||
| }, | |||||
| "AllowedOrigins": [ | |||||
| "http://localhost:5173", | |||||
| "http://localhost:5254", | |||||
| "http://kci-app01.ntp.kentcommunications.com" | |||||
| ], | |||||
| "Keycloak": { | |||||
| "Authority": "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI", | |||||
| "MetadataAddress": "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI/.well-known/openid-configuration", | |||||
| "ValidIssuer": "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI", | |||||
| "PublicAuthority": "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI", | |||||
| "ClientId": "canopy-web", | |||||
| "ClientSecret": "REPLACE-ON-SERVER", | |||||
| "DisableHttpsMetadata": true | |||||
| } | } | ||||
| } | } | ||||
| @@ -5,5 +5,17 @@ | |||||
| "Microsoft.AspNetCore": "Warning" | "Microsoft.AspNetCore": "Warning" | ||||
| } | } | ||||
| }, | }, | ||||
| "AllowedOrigins": [ | |||||
| "http://localhost:5254" | |||||
| ], | |||||
| "Keycloak": { | |||||
| "Authority": "http://localhost:8180/realms/KCI", | |||||
| "MetadataAddress": "http://localhost:8180/realms/KCI/.well-known/openid-configuration", | |||||
| "ValidIssuer": "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI", | |||||
| "PublicAuthority": "http://kci-app01.ntp.kentcommunications.com:8180/realms/KCI", | |||||
| "ClientId": "canopy-web", | |||||
| "ClientSecret": "REPLACE-ON-SERVER", | |||||
| "DisableHttpsMetadata": true | |||||
| }, | |||||
| "AllowedHosts": "*" | "AllowedHosts": "*" | ||||
| } | } | ||||
| @@ -1,6 +1,6 @@ | |||||
| # Story 1.3: Keycloak Realm Configuration & OIDC Integration | # Story 1.3: Keycloak Realm Configuration & OIDC Integration | ||||
| Status: ready-for-dev | |||||
| Status: review | |||||
| ## Story | ## Story | ||||
| @@ -18,18 +18,18 @@ so that I can securely access the application with my organizational credentials | |||||
| ## Tasks / Subtasks | ## Tasks / Subtasks | ||||
| - [ ] Implement story behavior in aligned backend/frontend modules (AC: #1) | |||||
| - [ ] Add or update API/service/UI components required by the story scope | |||||
| - [ ] Keep legacy Access entities read-only and route writes to extension-layer structures | |||||
| - [ ] Cover acceptance criteria #2 in implementation and tests (AC: #2) | |||||
| - [ ] Add validation/error handling and UX state updates as needed | |||||
| - [ ] Cover acceptance criteria #3 in implementation and tests (AC: #3) | |||||
| - [ ] Add validation/error handling and UX state updates as needed | |||||
| - [ ] Cover acceptance criteria #4 in implementation and tests (AC: #4) | |||||
| - [ ] Add validation/error handling and UX state updates as needed | |||||
| - [ ] Validate and document completion evidence | |||||
| - [ ] Verify build/tests for touched modules | |||||
| - [ ] Capture changed files and any migration/config implications | |||||
| - [x] Implement story behavior in aligned backend/frontend modules (AC: #1) | |||||
| - [x] Add or update API/service/UI components required by the story scope | |||||
| - [x] Keep legacy Access entities read-only and route writes to extension-layer structures | |||||
| - [x] Cover acceptance criteria #2 in implementation and tests (AC: #2) | |||||
| - [x] Add validation/error handling and UX state updates as needed | |||||
| - [x] Cover acceptance criteria #3 in implementation and tests (AC: #3) | |||||
| - [x] Add validation/error handling and UX state updates as needed | |||||
| - [x] Cover acceptance criteria #4 in implementation and tests (AC: #4) | |||||
| - [x] Add validation/error handling and UX state updates as needed | |||||
| - [x] Validate and document completion evidence | |||||
| - [x] Verify build/tests for touched modules | |||||
| - [x] Capture changed files and any migration/config implications | |||||
| ## Dev Notes | ## Dev Notes | ||||
| @@ -58,13 +58,67 @@ GPT-5 Codex | |||||
| ### Debug Log References | ### Debug Log References | ||||
| - Story generated from epic source and architecture/UX planning artifacts. | - Story generated from epic source and architecture/UX planning artifacts. | ||||
| - 2026-05-05: `dotnet test .\Campaign_Tracker.Server.Tests\Campaign_Tracker.Server.Tests.csproj` passed (4 tests). | |||||
| - 2026-05-05: `dotnet build .\campaign-tracker.sln` passed with 0 warnings and 0 errors. | |||||
| - 2026-05-05: `npm test` passed (2 files, 10 tests). | |||||
| - 2026-05-05: `npm run build` passed; Vite reported a large chunk warning for the existing Ant Design bundle. | |||||
| - 2026-05-05: `npm run lint` passed. | |||||
| - 2026-05-05: `dotnet build .\campaign-tracker.sln /p:UseAppHost=false` passed with 0 warnings and 0 errors after Canopy Keycloak config alignment. | |||||
| - 2026-05-05: `dotnet test .\Campaign_Tracker.Server.Tests\Campaign_Tracker.Server.Tests.csproj /p:UseAppHost=false` passed (4 tests) after Canopy Keycloak config alignment. | |||||
| - 2026-05-05: `dotnet test .\Campaign_Tracker.Server.Tests\Campaign_Tracker.Server.Tests.csproj /p:UseAppHost=false` passed (5 tests) after adding `.env` ClientSecret override support. | |||||
| - 2026-05-05: `dotnet build .\campaign-tracker.sln /p:UseAppHost=false` passed with 0 warnings and 0 errors after adding `.env` ClientSecret override support. | |||||
| - 2026-05-05: `dotnet test .\Campaign_Tracker.Server.Tests\Campaign_Tracker.Server.Tests.csproj /p:UseAppHost=false` passed (6 tests) after moving authorization-code and refresh-token exchange behind server endpoints. | |||||
| - 2026-05-05: `npm test` passed (2 files, 12 tests) after moving frontend token exchange to backend endpoints. | |||||
| - 2026-05-05: `dotnet build .\campaign-tracker.sln /p:UseAppHost=false` passed with 0 warnings and 0 errors after token exchange fix. | |||||
| - 2026-05-05: `npm run build` passed after token exchange fix; Vite reported the existing Ant Design large chunk warning. | |||||
| ### Completion Notes List | ### Completion Notes List | ||||
| - Story context created and marked ready-for-dev. | - Story context created and marked ready-for-dev. | ||||
| - Implemented API JWT bearer authentication configured for the Keycloak realm/audience, with `/api/auth/session` protected by authorization and returning the authenticated user's role-specific workspace path. | |||||
| - Added authentication audit capture for successful token validation and invalid bearer-token failures without recording sensitive token values. | |||||
| - Added frontend Keycloak authorization-code redirect handling, callback code exchange, session token storage, role workspace routing, and pre-request refresh-token renewal. | |||||
| - Protected the React operations shell so unauthenticated users redirect to Keycloak and callback URLs are cleaned after code exchange to avoid exposing token material in URLs. | |||||
| - No legacy Access write path was introduced; this story only adds authentication/session behavior and configuration. | |||||
| - Aligned configuration with the deployed Canopy realm shape: allowed origins, internal metadata address, public/valid issuer, `canopy-web` client ID, server-only client secret placeholder, and disabled HTTPS metadata for the current HTTP Keycloak endpoint. | |||||
| - Added a server-side `.env` configuration loader so `Keycloak__ClientSecret` overrides the `appsettings.Development.json` placeholder at startup without exposing the secret to the React client. | |||||
| - Moved authorization-code and refresh-token exchange to backend endpoints so confidential-client authentication uses the server-side Keycloak client secret instead of failing in the browser callback. | |||||
| ### File List | ### File List | ||||
| - `Campaign_Tracker.Server.Tests/AuthEndpointTests.cs` | |||||
| - `Campaign_Tracker.Server.Tests/Campaign_Tracker.Server.Tests.csproj` | |||||
| - `Campaign_Tracker.Server.Tests/DotEnvConfigurationTests.cs` | |||||
| - `Campaign_Tracker.Server.Tests/KeycloakTokenClientTests.cs` | |||||
| - `Campaign_Tracker.Server/Authentication/AuthenticationAuditEvent.cs` | |||||
| - `Campaign_Tracker.Server/Authentication/IAuthenticationAuditStore.cs` | |||||
| - `Campaign_Tracker.Server/Authentication/InMemoryAuthenticationAuditStore.cs` | |||||
| - `Campaign_Tracker.Server/Authentication/KeycloakTokenClient.cs` | |||||
| - `Campaign_Tracker.Server/Authentication/KeycloakOptions.cs` | |||||
| - `Campaign_Tracker.Server/Authentication/RoleWorkspaceResolver.cs` | |||||
| - `Campaign_Tracker.Server/Configuration/DotEnvConfiguration.cs` | |||||
| - `Campaign_Tracker.Server/Controllers/AuthSessionController.cs` | |||||
| - `Campaign_Tracker.Server/Controllers/AuthTokenController.cs` | |||||
| - `Campaign_Tracker.Server/Campaign_Tracker.Server.csproj` | |||||
| - `Campaign_Tracker.Server/Program.cs` | |||||
| - `Campaign_Tracker.Server/appsettings.Development.json` | |||||
| - `Campaign_Tracker.Server/appsettings.json` | |||||
| - `_bmad-output/implementation-artifacts/1-3-keycloak-realm-configuration-oidc-integration.md` | - `_bmad-output/implementation-artifacts/1-3-keycloak-realm-configuration-oidc-integration.md` | ||||
| - `_bmad-output/implementation-artifacts/sprint-status.yaml` | |||||
| - `campaign-tracker-client/src/App.css` | |||||
| - `campaign-tracker-client/src/App.tsx` | |||||
| - `campaign-tracker-client/src/auth/authContracts.test.ts` | |||||
| - `campaign-tracker-client/src/auth/authContracts.ts` | |||||
| - `campaign-tracker-client/src/auth/useOidcSession.ts` | |||||
| - `campaign-tracker-client/src/workspace/WorkspaceShell.tsx` | |||||
| ### Change Log | |||||
| | Date | Version | Description | Author | | |||||
| | --- | --- | --- | --- | | |||||
| | 2026-05-05 | 1.0 | Implemented Keycloak OIDC integration, JWT-protected API session endpoint, auth audit capture, frontend protected route/callback/refresh handling, and validation tests. | GPT-5 Codex | | |||||
| | 2026-05-05 | 1.1 | Aligned Keycloak and CORS configuration with Canopy deployment values. | GPT-5 Codex | | |||||
| | 2026-05-05 | 1.2 | Added server-side `.env` loading so Keycloak client secret overrides development placeholder at startup. | GPT-5 Codex | | |||||
| | 2026-05-05 | 1.3 | Moved Keycloak token exchange and refresh behind backend endpoints for confidential-client login. | GPT-5 Codex | | |||||
| @@ -35,7 +35,7 @@ | |||||
| # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | ||||
| generated: '2026-05-05T12:00:44-04:00' | generated: '2026-05-05T12:00:44-04:00' | ||||
| last_updated: '2026-05-05T13:11:00-04:00' | |||||
| last_updated: '2026-05-05T14:28:34-04:00' | |||||
| project: 'Campaign_Tracker App' | project: 'Campaign_Tracker App' | ||||
| project_key: 'NOKEY' | project_key: 'NOKEY' | ||||
| tracking_system: 'file-system' | tracking_system: 'file-system' | ||||
| @@ -45,7 +45,7 @@ development_status: | |||||
| epic-1: in-progress | epic-1: in-progress | ||||
| 1-1-project-initialization-solution-scaffold: review | 1-1-project-initialization-solution-scaffold: review | ||||
| 1-2-workspace-shell-ant-design-foundation: review | 1-2-workspace-shell-ant-design-foundation: review | ||||
| 1-3-keycloak-realm-configuration-oidc-integration: ready-for-dev | |||||
| 1-3-keycloak-realm-configuration-oidc-integration: review | |||||
| 1-4-keycloak-role-mapping-application-authorization: ready-for-dev | 1-4-keycloak-role-mapping-application-authorization: ready-for-dev | ||||
| 1-5-shared-audit-logging-infrastructure: ready-for-dev | 1-5-shared-audit-logging-infrastructure: ready-for-dev | ||||
| 1-6-legacy-anti-corruption-data-access-layer: ready-for-dev | 1-6-legacy-anti-corruption-data-access-layer: ready-for-dev | ||||
| @@ -1,3 +1,9 @@ | |||||
| .app-shell { | .app-shell { | ||||
| min-height: 100vh; | min-height: 100vh; | ||||
| } | } | ||||
| .app-auth-state { | |||||
| align-items: center; | |||||
| display: flex; | |||||
| justify-content: center; | |||||
| min-height: 100vh; | |||||
| } | |||||
| @@ -1,9 +1,23 @@ | |||||
| import { ConfigProvider, theme } from 'antd' | |||||
| import { ConfigProvider, Result, Spin, theme } from 'antd' | |||||
| import './App.css' | import './App.css' | ||||
| import { useOidcSession } from './auth/useOidcSession' | |||||
| import { WorkspaceShell } from './workspace/WorkspaceShell' | import { WorkspaceShell } from './workspace/WorkspaceShell' | ||||
| import { workspaceThemeTokens } from './workspace/workspaceContracts' | import { workspaceThemeTokens } from './workspace/workspaceContracts' | ||||
| function App() { | function App() { | ||||
| const session = useOidcSession() | |||||
| const content = | |||||
| session.status === 'authenticated' ? ( | |||||
| <WorkspaceShell user={session.user} /> | |||||
| ) : session.status === 'error' ? ( | |||||
| <Result status="warning" title={session.error} /> | |||||
| ) : ( | |||||
| <div className="app-auth-state" aria-live="polite"> | |||||
| <Spin /> | |||||
| </div> | |||||
| ) | |||||
| return ( | return ( | ||||
| <ConfigProvider | <ConfigProvider | ||||
| theme={{ | theme={{ | ||||
| @@ -22,7 +36,7 @@ function App() { | |||||
| }} | }} | ||||
| > | > | ||||
| <div className="app-shell"> | <div className="app-shell"> | ||||
| <WorkspaceShell /> | |||||
| {content} | |||||
| </div> | </div> | ||||
| </ConfigProvider> | </ConfigProvider> | ||||
| ) | ) | ||||
| @@ -0,0 +1,131 @@ | |||||
| import { afterEach, describe, expect, it, vi } from 'vitest' | |||||
| import { | |||||
| buildKeycloakAuthorizationUrl, | |||||
| buildKeycloakTokenUrl, | |||||
| exchangeAuthorizationCode, | |||||
| getRoleWorkspacePath, | |||||
| isAuthCallbackPath, | |||||
| isTokenRefreshRequired, | |||||
| refreshAccessToken, | |||||
| shouldRedirectToLogin, | |||||
| type AuthTokenSet, | |||||
| type KeycloakClientConfig, | |||||
| } from './authContracts' | |||||
| const config: KeycloakClientConfig = { | |||||
| authority: 'https://keycloak.example.test', | |||||
| realm: 'campaign-tracker', | |||||
| clientId: 'campaign-tracker-client', | |||||
| redirectUri: 'https://app.example.test/auth/callback', | |||||
| } | |||||
| afterEach(() => { | |||||
| vi.restoreAllMocks() | |||||
| }) | |||||
| describe('oidc auth contracts', () => { | |||||
| it('builds a Keycloak authorization-code URL without token values in the query string', () => { | |||||
| const url = buildKeycloakAuthorizationUrl(config, 'state-123', 'nonce-456') | |||||
| expect(url.origin).toBe('https://keycloak.example.test') | |||||
| expect(url.pathname).toBe('/realms/campaign-tracker/protocol/openid-connect/auth') | |||||
| expect(url.searchParams.get('response_type')).toBe('code') | |||||
| expect(url.searchParams.get('scope')).toBe('openid profile email') | |||||
| expect(url.searchParams.get('client_id')).toBe('campaign-tracker-client') | |||||
| expect(url.searchParams.get('redirect_uri')).toBe('https://app.example.test/auth/callback') | |||||
| expect(url.searchParams.has('access_token')).toBe(false) | |||||
| expect(url.searchParams.has('refresh_token')).toBe(false) | |||||
| }) | |||||
| it('builds the realm token endpoint for code exchange and refresh', () => { | |||||
| const url = buildKeycloakTokenUrl(config) | |||||
| expect(url.toString()).toBe( | |||||
| 'https://keycloak.example.test/realms/campaign-tracker/protocol/openid-connect/token', | |||||
| ) | |||||
| }) | |||||
| it('exchanges authorization codes through the server so client secrets stay server-side', async () => { | |||||
| const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( | |||||
| new Response( | |||||
| JSON.stringify({ | |||||
| accessToken: 'access-token', | |||||
| refreshToken: 'refresh-token', | |||||
| expiresAtEpochSeconds: 1000, | |||||
| }), | |||||
| { status: 200 }, | |||||
| ), | |||||
| ) | |||||
| const tokens = await exchangeAuthorizationCode(config, 'auth-code') | |||||
| expect(tokens.accessToken).toBe('access-token') | |||||
| expect(fetchMock).toHaveBeenCalledWith('/api/auth/token/exchange', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ | |||||
| code: 'auth-code', | |||||
| redirectUri: 'https://app.example.test/auth/callback', | |||||
| }), | |||||
| }) | |||||
| expect(fetchMock.mock.calls[0][1]?.body).not.toContain('client_secret') | |||||
| }) | |||||
| it('refreshes access tokens through the server token endpoint', async () => { | |||||
| const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( | |||||
| new Response( | |||||
| JSON.stringify({ | |||||
| accessToken: 'new-access-token', | |||||
| refreshToken: 'new-refresh-token', | |||||
| expiresAtEpochSeconds: 1200, | |||||
| }), | |||||
| { status: 200 }, | |||||
| ), | |||||
| ) | |||||
| const tokens = await refreshAccessToken(config, 'old-refresh-token') | |||||
| expect(tokens.refreshToken).toBe('new-refresh-token') | |||||
| expect(fetchMock).toHaveBeenCalledWith('/api/auth/token/refresh', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ refreshToken: 'old-refresh-token' }), | |||||
| }) | |||||
| }) | |||||
| it('redirects protected routes to login only when no access token is available', () => { | |||||
| expect(shouldRedirectToLogin('/workspace/client-services', null)).toBe(true) | |||||
| expect( | |||||
| shouldRedirectToLogin('/workspace/client-services', { | |||||
| accessToken: 'token', | |||||
| refreshToken: 'refresh', | |||||
| expiresAtEpochSeconds: 1000, | |||||
| }), | |||||
| ).toBe(false) | |||||
| expect(shouldRedirectToLogin('/auth/callback', null)).toBe(false) | |||||
| }) | |||||
| it('recognizes auth callback paths independent of casing', () => { | |||||
| expect(isAuthCallbackPath('/auth/callback')).toBe(true) | |||||
| expect(isAuthCallbackPath('/AUTH/CALLBACK')).toBe(true) | |||||
| expect(isAuthCallbackPath('/workspace')).toBe(false) | |||||
| }) | |||||
| it('refreshes tokens before expiry so authenticated requests can continue silently', () => { | |||||
| const tokens: AuthTokenSet = { | |||||
| accessToken: 'token', | |||||
| refreshToken: 'refresh', | |||||
| expiresAtEpochSeconds: 120, | |||||
| } | |||||
| expect(isTokenRefreshRequired(tokens, 70)).toBe(true) | |||||
| expect(isTokenRefreshRequired(tokens, 10)).toBe(false) | |||||
| }) | |||||
| it('maps Keycloak roles to the role-specific workspace path', () => { | |||||
| expect(getRoleWorkspacePath(['production-lead'])).toBe('/workspace/production') | |||||
| expect(getRoleWorkspacePath(['client-services'])).toBe('/workspace/client-services') | |||||
| expect(getRoleWorkspacePath(['support-analyst'])).toBe('/workspace/support') | |||||
| expect(getRoleWorkspacePath([])).toBe('/workspace') | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,253 @@ | |||||
| export type KeycloakClientConfig = { | |||||
| authority: string | |||||
| realm: string | |||||
| clientId: string | |||||
| redirectUri: string | |||||
| } | |||||
| export type AuthTokenSet = { | |||||
| accessToken: string | |||||
| refreshToken: string | |||||
| expiresAtEpochSeconds: number | |||||
| } | |||||
| export type AuthenticatedUser = { | |||||
| userName: string | |||||
| roles: string[] | |||||
| workspacePath: string | |||||
| } | |||||
| export const authStorageKey = 'campaign-tracker.auth.tokens' | |||||
| export const oidcReturnPathStorageKey = 'campaign-tracker.auth.returnPath' | |||||
| export function getKeycloakClientConfig(): KeycloakClientConfig { | |||||
| const env = import.meta.env | |||||
| const authority = | |||||
| env.VITE_KEYCLOAK_AUTHORITY ?? | |||||
| 'http://kci-app01.ntp.kentcommunications.com:8180' | |||||
| const realm = env.VITE_KEYCLOAK_REALM ?? 'KCI' | |||||
| const clientId = env.VITE_KEYCLOAK_CLIENT_ID ?? 'canopy-web' | |||||
| const redirectUri = | |||||
| env.VITE_KEYCLOAK_REDIRECT_URI ?? | |||||
| 'http://kci-app01.ntp.kentcommunications.com/auth/callback' | |||||
| return { authority, realm, clientId, redirectUri } | |||||
| } | |||||
| export function buildKeycloakAuthorizationUrl( | |||||
| config: KeycloakClientConfig, | |||||
| state: string, | |||||
| nonce: string, | |||||
| ) { | |||||
| const url = new URL( | |||||
| `/realms/${encodeURIComponent(config.realm)}/protocol/openid-connect/auth`, | |||||
| normalizeAuthority(config.authority), | |||||
| ) | |||||
| url.searchParams.set('client_id', config.clientId) | |||||
| url.searchParams.set('redirect_uri', config.redirectUri) | |||||
| url.searchParams.set('response_type', 'code') | |||||
| url.searchParams.set('scope', 'openid profile email') | |||||
| url.searchParams.set('state', state) | |||||
| url.searchParams.set('nonce', nonce) | |||||
| return url | |||||
| } | |||||
| export function buildKeycloakTokenUrl(config: KeycloakClientConfig) { | |||||
| return new URL( | |||||
| `/realms/${encodeURIComponent(config.realm)}/protocol/openid-connect/token`, | |||||
| normalizeAuthority(config.authority), | |||||
| ) | |||||
| } | |||||
| export function shouldRedirectToLogin( | |||||
| pathname: string, | |||||
| tokens: AuthTokenSet | null, | |||||
| config?: KeycloakClientConfig, | |||||
| ) { | |||||
| const callbackPath = config ? getAuthCallbackPath(config) : '/auth/callback' | |||||
| if (isAuthCallbackPath(pathname, callbackPath)) { | |||||
| return false | |||||
| } | |||||
| return tokens === null | |||||
| } | |||||
| export function isTokenRefreshRequired( | |||||
| tokens: AuthTokenSet, | |||||
| nowEpochSeconds = Math.floor(Date.now() / 1000), | |||||
| refreshWindowSeconds = 60, | |||||
| ) { | |||||
| return tokens.expiresAtEpochSeconds - nowEpochSeconds <= refreshWindowSeconds | |||||
| } | |||||
| export function getRoleWorkspacePath(roles: string[]) { | |||||
| if (hasRole(roles, 'client-services')) { | |||||
| return '/workspace/client-services' | |||||
| } | |||||
| if (hasRole(roles, 'production-lead')) { | |||||
| return '/workspace/production' | |||||
| } | |||||
| if (hasRole(roles, 'transportation')) { | |||||
| return '/workspace/transportation' | |||||
| } | |||||
| if (hasRole(roles, 'operations-admin')) { | |||||
| return '/workspace/admin' | |||||
| } | |||||
| if (hasRole(roles, 'support-analyst')) { | |||||
| return '/workspace/support' | |||||
| } | |||||
| return '/workspace' | |||||
| } | |||||
| export function readStoredAuthTokenSet(storage = window.sessionStorage) { | |||||
| const value = storage.getItem(authStorageKey) | |||||
| if (!value) { | |||||
| return null | |||||
| } | |||||
| try { | |||||
| return JSON.parse(value) as AuthTokenSet | |||||
| } catch { | |||||
| storage.removeItem(authStorageKey) | |||||
| return null | |||||
| } | |||||
| } | |||||
| export function storeAuthTokenSet(tokens: AuthTokenSet, storage = window.sessionStorage) { | |||||
| storage.setItem(authStorageKey, JSON.stringify(tokens)) | |||||
| } | |||||
| export function clearStoredAuthTokenSet(storage = window.sessionStorage) { | |||||
| storage.removeItem(authStorageKey) | |||||
| } | |||||
| export function getAuthCallbackPath(config: KeycloakClientConfig) { | |||||
| return new URL(config.redirectUri).pathname | |||||
| } | |||||
| export function isAuthCallbackPath(pathname: string, callbackPath = '/auth/callback') { | |||||
| return pathname.toLowerCase() === callbackPath.toLowerCase() | |||||
| } | |||||
| export function decodeAuthenticatedUser(accessToken: string): AuthenticatedUser { | |||||
| const payload = decodeJwtPayload(accessToken) | |||||
| const roles = getRolesFromPayload(payload) | |||||
| const userName = | |||||
| getString(payload.preferred_username) ?? | |||||
| getString(payload.email) ?? | |||||
| getString(payload.name) ?? | |||||
| getString(payload.sub) ?? | |||||
| 'unknown' | |||||
| return { | |||||
| userName, | |||||
| roles, | |||||
| workspacePath: getRoleWorkspacePath(roles), | |||||
| } | |||||
| } | |||||
| export async function exchangeAuthorizationCode( | |||||
| config: KeycloakClientConfig, | |||||
| code: string, | |||||
| ) { | |||||
| return requestTokenSet('/api/auth/token/exchange', { | |||||
| code, | |||||
| redirectUri: config.redirectUri, | |||||
| }) | |||||
| } | |||||
| export async function refreshAccessToken( | |||||
| _config: KeycloakClientConfig, | |||||
| refreshToken: string, | |||||
| ) { | |||||
| return requestTokenSet('/api/auth/token/refresh', { | |||||
| refreshToken, | |||||
| }) | |||||
| } | |||||
| export async function authenticatedFetch( | |||||
| input: RequestInfo | URL, | |||||
| init: RequestInit, | |||||
| config: KeycloakClientConfig, | |||||
| tokens: AuthTokenSet, | |||||
| onTokensChanged: (tokens: AuthTokenSet) => void, | |||||
| ) { | |||||
| const activeTokens = isTokenRefreshRequired(tokens) | |||||
| ? await refreshAccessToken(config, tokens.refreshToken) | |||||
| : tokens | |||||
| if (activeTokens !== tokens) { | |||||
| onTokensChanged(activeTokens) | |||||
| } | |||||
| const headers = new Headers(init.headers) | |||||
| headers.set('Authorization', `Bearer ${activeTokens.accessToken}`) | |||||
| return fetch(input, { ...init, headers }) | |||||
| } | |||||
| async function requestTokenSet(endpoint: string, values: Record<string, string>) { | |||||
| const response = await fetch(endpoint, { | |||||
| method: 'POST', | |||||
| headers: { | |||||
| 'Content-Type': 'application/json', | |||||
| }, | |||||
| body: JSON.stringify(values), | |||||
| }) | |||||
| if (!response.ok) { | |||||
| throw new Error('Authentication token request failed') | |||||
| } | |||||
| const payload = (await response.json()) as { | |||||
| accessToken: string | |||||
| refreshToken: string | |||||
| expiresAtEpochSeconds: number | |||||
| } | |||||
| return { | |||||
| accessToken: payload.accessToken, | |||||
| refreshToken: payload.refreshToken, | |||||
| expiresAtEpochSeconds: payload.expiresAtEpochSeconds, | |||||
| } satisfies AuthTokenSet | |||||
| } | |||||
| function normalizeAuthority(authority: string) { | |||||
| return authority.endsWith('/') ? authority : `${authority}/` | |||||
| } | |||||
| function hasRole(roles: string[], role: string) { | |||||
| return roles.some((candidate) => candidate.toLowerCase() === role) | |||||
| } | |||||
| function decodeJwtPayload(accessToken: string) { | |||||
| const [, payload] = accessToken.split('.') | |||||
| if (!payload) { | |||||
| return {} | |||||
| } | |||||
| const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') | |||||
| const decoded = window.atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')) | |||||
| return JSON.parse(decoded) as Record<string, unknown> | |||||
| } | |||||
| function getRolesFromPayload(payload: Record<string, unknown>) { | |||||
| const directRoles = Array.isArray(payload.roles) ? payload.roles : [] | |||||
| const realmAccess = payload.realm_access as { roles?: unknown } | undefined | |||||
| const realmRoles = Array.isArray(realmAccess?.roles) ? realmAccess.roles : [] | |||||
| return [...directRoles, ...realmRoles] | |||||
| .filter((role): role is string => typeof role === 'string') | |||||
| .filter((role, index, roles) => roles.indexOf(role) === index) | |||||
| } | |||||
| function getString(value: unknown) { | |||||
| return typeof value === 'string' && value.length > 0 ? value : undefined | |||||
| } | |||||
| @@ -0,0 +1,117 @@ | |||||
| import { useEffect, useMemo, useState } from 'react' | |||||
| import { | |||||
| buildKeycloakAuthorizationUrl, | |||||
| decodeAuthenticatedUser, | |||||
| exchangeAuthorizationCode, | |||||
| getAuthCallbackPath, | |||||
| getKeycloakClientConfig, | |||||
| isAuthCallbackPath, | |||||
| isTokenRefreshRequired, | |||||
| oidcReturnPathStorageKey, | |||||
| readStoredAuthTokenSet, | |||||
| refreshAccessToken, | |||||
| shouldRedirectToLogin, | |||||
| storeAuthTokenSet, | |||||
| type AuthTokenSet, | |||||
| type AuthenticatedUser, | |||||
| } from './authContracts' | |||||
| type OidcSessionState = | |||||
| | { status: 'checking'; user: null; tokens: null; error: null } | |||||
| | { status: 'redirecting'; user: null; tokens: null; error: null } | |||||
| | { status: 'authenticated'; user: AuthenticatedUser; tokens: AuthTokenSet; error: null } | |||||
| | { status: 'error'; user: null; tokens: null; error: string } | |||||
| export function useOidcSession(): OidcSessionState { | |||||
| const config = useMemo(() => getKeycloakClientConfig(), []) | |||||
| const [state, setState] = useState<OidcSessionState>({ | |||||
| status: 'checking', | |||||
| user: null, | |||||
| tokens: null, | |||||
| error: null, | |||||
| }) | |||||
| useEffect(() => { | |||||
| let cancelled = false | |||||
| async function syncSession() { | |||||
| try { | |||||
| const currentUrl = new URL(window.location.href) | |||||
| const callbackPath = getAuthCallbackPath(config) | |||||
| const storedTokens = readStoredAuthTokenSet() | |||||
| if (isAuthCallbackPath(currentUrl.pathname, callbackPath)) { | |||||
| const code = currentUrl.searchParams.get('code') | |||||
| if (!code) { | |||||
| throw new Error('Missing Keycloak authorization code') | |||||
| } | |||||
| const tokens = await exchangeAuthorizationCode(config, code) | |||||
| storeAuthTokenSet(tokens) | |||||
| const user = decodeAuthenticatedUser(tokens.accessToken) | |||||
| const returnPath = | |||||
| window.sessionStorage.getItem(oidcReturnPathStorageKey) ?? user.workspacePath | |||||
| window.sessionStorage.removeItem(oidcReturnPathStorageKey) | |||||
| window.history.replaceState({}, document.title, returnPath) | |||||
| if (!cancelled) { | |||||
| setState({ status: 'authenticated', user, tokens, error: null }) | |||||
| } | |||||
| return | |||||
| } | |||||
| if (storedTokens) { | |||||
| const tokens = isTokenRefreshRequired(storedTokens) | |||||
| ? await refreshAccessToken(config, storedTokens.refreshToken) | |||||
| : storedTokens | |||||
| if (tokens !== storedTokens) { | |||||
| storeAuthTokenSet(tokens) | |||||
| } | |||||
| if (!cancelled) { | |||||
| setState({ | |||||
| status: 'authenticated', | |||||
| user: decodeAuthenticatedUser(tokens.accessToken), | |||||
| tokens, | |||||
| error: null, | |||||
| }) | |||||
| } | |||||
| return | |||||
| } | |||||
| if (shouldRedirectToLogin(window.location.pathname, null, config)) { | |||||
| window.sessionStorage.setItem( | |||||
| oidcReturnPathStorageKey, | |||||
| `${window.location.pathname}${window.location.search}`, | |||||
| ) | |||||
| setState({ status: 'redirecting', user: null, tokens: null, error: null }) | |||||
| window.location.assign( | |||||
| buildKeycloakAuthorizationUrl( | |||||
| config, | |||||
| crypto.randomUUID(), | |||||
| crypto.randomUUID(), | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } catch { | |||||
| if (!cancelled) { | |||||
| setState({ | |||||
| status: 'error', | |||||
| user: null, | |||||
| tokens: null, | |||||
| error: 'Authentication failed. Please sign in again.', | |||||
| }) | |||||
| } | |||||
| } | |||||
| } | |||||
| void syncSession() | |||||
| return () => { | |||||
| cancelled = true | |||||
| } | |||||
| }, [config]) | |||||
| return state | |||||
| } | |||||
| @@ -30,6 +30,7 @@ import { | |||||
| workspaceThemeTokens, | workspaceThemeTokens, | ||||
| type WorkspaceStatus, | type WorkspaceStatus, | ||||
| } from './workspaceContracts' | } from './workspaceContracts' | ||||
| import type { AuthenticatedUser } from '../auth/authContracts' | |||||
| import './WorkspaceShell.css' | import './WorkspaceShell.css' | ||||
| const { Header, Sider, Content } = Layout | const { Header, Sider, Content } = Layout | ||||
| @@ -228,7 +229,7 @@ function RiskPanel({ | |||||
| ) | ) | ||||
| } | } | ||||
| export function WorkspaceShell() { | |||||
| export function WorkspaceShell({ user }: { user: AuthenticatedUser }) { | |||||
| const width = useViewportWidth() | const width = useViewportWidth() | ||||
| const editingAvailable = isEditingAvailable(width) | const editingAvailable = isEditingAvailable(width) | ||||
| const canCollapseRightPanel = isRightPanelCollapsible(width) | const canCollapseRightPanel = isRightPanelCollapsible(width) | ||||
| @@ -296,7 +297,7 @@ export function WorkspaceShell() { | |||||
| <Header className="workspace-header"> | <Header className="workspace-header"> | ||||
| <Space align="center" size={12}> | <Space align="center" size={12}> | ||||
| <Badge color={workspaceThemeTokens.colorPrimary} text="Primary workspace" /> | <Badge color={workspaceThemeTokens.colorPrimary} text="Primary workspace" /> | ||||
| <Text type="secondary">Authenticated operations shell</Text> | |||||
| <Text type="secondary">{user.userName}</Text> | |||||
| </Space> | </Space> | ||||
| <Space> | <Space> | ||||
| <Button disabled={!editingAvailable}>Save View</Button> | <Button disabled={!editingAvailable}>Save View</Button> | ||||
| @@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react' | |||||
| // https://vite.dev/config/ | // https://vite.dev/config/ | ||||
| export default defineConfig({ | export default defineConfig({ | ||||
| plugins: [react()], | plugins: [react()], | ||||
| server: { | |||||
| proxy: { | |||||
| '/api': { | |||||
| target: 'https://localhost:7244', | |||||
| changeOrigin: true, | |||||
| secure: false, | |||||
| }, | |||||
| }, | |||||
| }, | |||||
| }) | }) | ||||
| @@ -0,0 +1,7 @@ | |||||
| { | |||||
| "name": "Campaign_Tracker App", | |||||
| "lockfileVersion": 3, | |||||
| "requires": true, | |||||
| "packages": {} | |||||
| } | |||||
Powered by TurnKey Linux.