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