Browse Source

Implement Keycloak OIDC auth flow

- 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 tests
pull/15/head
Daniel Covington 3 days ago
parent
commit
6ec1b34821
28 changed files with 5548 additions and 19 deletions
  1. +2
    -0
      .gitignore
  2. +107
    -0
      Campaign_Tracker.Server.Tests/AuthEndpointTests.cs
  3. +1
    -0
      Campaign_Tracker.Server.Tests/Campaign_Tracker.Server.Tests.csproj
  4. +42
    -0
      Campaign_Tracker.Server.Tests/DotEnvConfigurationTests.cs
  5. +63
    -0
      Campaign_Tracker.Server.Tests/KeycloakTokenClientTests.cs
  6. +14
    -0
      Campaign_Tracker.Server/Authentication/AuthenticationAuditEvent.cs
  7. +10
    -0
      Campaign_Tracker.Server/Authentication/IAuthenticationAuditStore.cs
  8. +30
    -0
      Campaign_Tracker.Server/Authentication/InMemoryAuthenticationAuditStore.cs
  9. +24
    -0
      Campaign_Tracker.Server/Authentication/KeycloakOptions.cs
  10. +137
    -0
      Campaign_Tracker.Server/Authentication/KeycloakTokenClient.cs
  11. +27
    -0
      Campaign_Tracker.Server/Authentication/RoleWorkspaceResolver.cs
  12. +58
    -0
      Campaign_Tracker.Server/Configuration/DotEnvConfiguration.cs
  13. +34
    -0
      Campaign_Tracker.Server/Controllers/AuthSessionController.cs
  14. +87
    -0
      Campaign_Tracker.Server/Controllers/AuthTokenController.cs
  15. +113
    -0
      Campaign_Tracker.Server/Program.cs
  16. +14
    -0
      Campaign_Tracker.Server/appsettings.Development.json
  17. +12
    -0
      Campaign_Tracker.Server/appsettings.json
  18. +67
    -13
      _bmad-output/implementation-artifacts/1-3-keycloak-realm-configuration-oidc-integration.md
  19. +2
    -2
      _bmad-output/implementation-artifacts/sprint-status.yaml
  20. +4162
    -0
      campaign-tracker-client/package-lock.json
  21. +6
    -0
      campaign-tracker-client/src/App.css
  22. +16
    -2
      campaign-tracker-client/src/App.tsx
  23. +131
    -0
      campaign-tracker-client/src/auth/authContracts.test.ts
  24. +253
    -0
      campaign-tracker-client/src/auth/authContracts.ts
  25. +117
    -0
      campaign-tracker-client/src/auth/useOidcSession.ts
  26. +3
    -2
      campaign-tracker-client/src/workspace/WorkspaceShell.tsx
  27. +9
    -0
      campaign-tracker-client/vite.config.ts
  28. +7
    -0
      package-lock.json

+ 2
- 0
.gitignore View File

@@ -17,3 +17,5 @@ coverage/
*.DS_Store
Thumbs.db
Campaign_Tracker.Server/Campaign_Tracker.Server.csproj
.env
Campaign_Tracker.Server/appsettings.Development.json

+ 107
- 0
Campaign_Tracker.Server.Tests/AuthEndpointTests.cs View File

@@ -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;
}
}

+ 1
- 0
Campaign_Tracker.Server.Tests/Campaign_Tracker.Server.Tests.csproj View File

@@ -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>


+ 42
- 0
Campaign_Tracker.Server.Tests/DotEnvConfigurationTests.cs View File

@@ -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);
}
}
}

+ 63
- 0
Campaign_Tracker.Server.Tests/KeycloakTokenClientTests.cs View File

@@ -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,
})),
};
}
}
}

+ 14
- 0
Campaign_Tracker.Server/Authentication/AuthenticationAuditEvent.cs View File

@@ -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);

+ 10
- 0
Campaign_Tracker.Server/Authentication/IAuthenticationAuditStore.cs View File

@@ -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);
}

+ 30
- 0
Campaign_Tracker.Server/Authentication/InMemoryAuthenticationAuditStore.cs View File

@@ -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));
}
}

+ 24
- 0
Campaign_Tracker.Server/Authentication/KeycloakOptions.cs View File

@@ -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;
}

+ 137
- 0
Campaign_Tracker.Server/Authentication/KeycloakTokenClient.cs View File

@@ -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; }
}

+ 27
- 0
Campaign_Tracker.Server/Authentication/RoleWorkspaceResolver.cs View File

@@ -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";
}
}

+ 58
- 0
Campaign_Tracker.Server/Configuration/DotEnvConfiguration.cs View File

@@ -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;
}
}

+ 34
- 0
Campaign_Tracker.Server/Controllers/AuthSessionController.cs View File

@@ -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);

+ 87
- 0
Campaign_Tracker.Server/Controllers/AuthTokenController.cs View File

@@ -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);

+ 113
- 0
Campaign_Tracker.Server/Program.cs View File

@@ -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();


+ 14
- 0
Campaign_Tracker.Server/appsettings.Development.json View File

@@ -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
}
}

+ 12
- 0
Campaign_Tracker.Server/appsettings.json View File

@@ -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": "*"
}

+ 67
- 13
_bmad-output/implementation-artifacts/1-3-keycloak-realm-configuration-oidc-integration.md View File

@@ -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 |



+ 2
- 2
_bmad-output/implementation-artifacts/sprint-status.yaml View File

@@ -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


+ 4162
- 0
campaign-tracker-client/package-lock.json
File diff suppressed because it is too large
View File


+ 6
- 0
campaign-tracker-client/src/App.css View File

@@ -1,3 +1,9 @@
.app-shell {
min-height: 100vh;
}
.app-auth-state {
align-items: center;
display: flex;
justify-content: center;
min-height: 100vh;
}

+ 16
- 2
campaign-tracker-client/src/App.tsx View File

@@ -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>
)


+ 131
- 0
campaign-tracker-client/src/auth/authContracts.test.ts View File

@@ -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')
})
})

+ 253
- 0
campaign-tracker-client/src/auth/authContracts.ts View File

@@ -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
}

+ 117
- 0
campaign-tracker-client/src/auth/useOidcSession.ts View File

@@ -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
}

+ 3
- 2
campaign-tracker-client/src/workspace/WorkspaceShell.tsx View File

@@ -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>


+ 9
- 0
campaign-tracker-client/vite.config.ts View File

@@ -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,
},
},
},
})

+ 7
- 0
package-lock.json View File

@@ -0,0 +1,7 @@
{
"name": "Campaign_Tracker App",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}


Loading…
Cancel
Save

Powered by TurnKey Linux.