- 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 | |||
| Thumbs.db | |||
| 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="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" /> | |||
| <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.runner.visualstudio" Version="3.1.4" /> | |||
| </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); | |||
| DotEnvConfiguration.Load(builder.Configuration, builder.Environment.ContentRootPath); | |||
| // Add services to the container. | |||
| builder.Services.AddControllers(); | |||
| // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi | |||
| 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(); | |||
| @@ -16,6 +127,8 @@ if (app.Environment.IsDevelopment()) | |||
| app.UseHttpsRedirection(); | |||
| app.UseCors("ConfiguredOrigins"); | |||
| app.UseAuthentication(); | |||
| app.UseAuthorization(); | |||
| app.MapControllers(); | |||
| @@ -4,5 +4,19 @@ | |||
| "Default": "Information", | |||
| "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" | |||
| } | |||
| }, | |||
| "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": "*" | |||
| } | |||
| @@ -1,6 +1,6 @@ | |||
| # Story 1.3: Keycloak Realm Configuration & OIDC Integration | |||
| Status: ready-for-dev | |||
| Status: review | |||
| ## Story | |||
| @@ -18,18 +18,18 @@ so that I can securely access the application with my organizational credentials | |||
| ## 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 | |||
| @@ -58,13 +58,67 @@ GPT-5 Codex | |||
| ### Debug Log References | |||
| - 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 | |||
| - 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 | |||
| - `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/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) | |||
| 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_key: 'NOKEY' | |||
| tracking_system: 'file-system' | |||
| @@ -45,7 +45,7 @@ development_status: | |||
| epic-1: in-progress | |||
| 1-1-project-initialization-solution-scaffold: 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-5-shared-audit-logging-infrastructure: ready-for-dev | |||
| 1-6-legacy-anti-corruption-data-access-layer: ready-for-dev | |||
| @@ -1,3 +1,9 @@ | |||
| .app-shell { | |||
| 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 { useOidcSession } from './auth/useOidcSession' | |||
| import { WorkspaceShell } from './workspace/WorkspaceShell' | |||
| import { workspaceThemeTokens } from './workspace/workspaceContracts' | |||
| 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 ( | |||
| <ConfigProvider | |||
| theme={{ | |||
| @@ -22,7 +36,7 @@ function App() { | |||
| }} | |||
| > | |||
| <div className="app-shell"> | |||
| <WorkspaceShell /> | |||
| {content} | |||
| </div> | |||
| </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, | |||
| type WorkspaceStatus, | |||
| } from './workspaceContracts' | |||
| import type { AuthenticatedUser } from '../auth/authContracts' | |||
| import './WorkspaceShell.css' | |||
| const { Header, Sider, Content } = Layout | |||
| @@ -228,7 +229,7 @@ function RiskPanel({ | |||
| ) | |||
| } | |||
| export function WorkspaceShell() { | |||
| export function WorkspaceShell({ user }: { user: AuthenticatedUser }) { | |||
| const width = useViewportWidth() | |||
| const editingAvailable = isEditingAvailable(width) | |||
| const canCollapseRightPanel = isRightPanelCollapsible(width) | |||
| @@ -296,7 +297,7 @@ export function WorkspaceShell() { | |||
| <Header className="workspace-header"> | |||
| <Space align="center" size={12}> | |||
| <Badge color={workspaceThemeTokens.colorPrimary} text="Primary workspace" /> | |||
| <Text type="secondary">Authenticated operations shell</Text> | |||
| <Text type="secondary">{user.userName}</Text> | |||
| </Space> | |||
| <Space> | |||
| <Button disabled={!editingAvailable}>Save View</Button> | |||
| @@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react' | |||
| // https://vite.dev/config/ | |||
| export default defineConfig({ | |||
| 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.