using System.Net; using System.Text.Json.Serialization; using Microsoft.Extensions.Options; namespace Campaign_Tracker.Server.Authentication; public interface IKeycloakTokenClient { Task ExchangeAuthorizationCodeAsync( string code, string redirectUri, CancellationToken cancellationToken); Task RefreshAccessTokenAsync( string refreshToken, CancellationToken cancellationToken); Task EndSessionAsync(string idTokenHint, CancellationToken cancellationToken); } public sealed class KeycloakTokenClient : IKeycloakTokenClient { private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly KeycloakOptions _options; public KeycloakTokenClient( HttpClient httpClient, IOptions options, ILogger logger) { _httpClient = httpClient; _logger = logger; _options = options.Value; } public Task ExchangeAuthorizationCodeAsync( string code, string redirectUri, CancellationToken cancellationToken) { return RequestTokenSetAsync( new Dictionary { ["grant_type"] = "authorization_code", ["client_id"] = _options.ClientId, ["client_secret"] = GetClientSecret(), ["redirect_uri"] = redirectUri, ["code"] = code, }, cancellationToken); } public Task RefreshAccessTokenAsync( string refreshToken, CancellationToken cancellationToken) { return RequestTokenSetAsync( new Dictionary { ["grant_type"] = "refresh_token", ["client_id"] = _options.ClientId, ["client_secret"] = GetClientSecret(), ["refresh_token"] = refreshToken, }, cancellationToken); } public async Task EndSessionAsync(string idTokenHint, CancellationToken cancellationToken) { using var content = new FormUrlEncodedContent(new Dictionary { ["id_token_hint"] = idTokenHint, ["client_id"] = _options.ClientId, ["client_secret"] = GetClientSecret(), }); using var response = await _httpClient.PostAsync(EndSessionEndpoint, content, cancellationToken); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogWarning( "Keycloak end-session request failed with {StatusCode}: {ErrorBody}", (int)response.StatusCode, errorBody); throw new KeycloakTokenRequestException(response.StatusCode, errorBody); } } private async Task RequestTokenSetAsync( Dictionary 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(cancellationToken) ?? throw new InvalidOperationException("Keycloak token response was empty."); return new AuthTokenSetResponse( payload.AccessToken, payload.RefreshToken, DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn).ToUnixTimeSeconds(), string.IsNullOrEmpty(payload.IdToken) ? null : payload.IdToken); } private string TokenEndpoint => $"{_options.TokenEndpointAuthority.TrimEnd('/')}/protocol/openid-connect/token"; private string EndSessionEndpoint => $"{_options.TokenEndpointAuthority.TrimEnd('/')}/protocol/openid-connect/logout"; 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, string? IdToken = null); 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; } [JsonPropertyName("id_token")] public string IdToken { get; init; } = string.Empty; }