using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Options; namespace ChessCubing.Server.Auth; public sealed class KeycloakAuthService(HttpClient httpClient, IOptions options) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; private readonly HttpClient _httpClient = httpClient; private readonly KeycloakAuthOptions _options = options.Value; public async Task LoginAsync(string username, string password, CancellationToken cancellationToken) { var tokenResponse = await RequestPasswordTokenAsync(username, password, cancellationToken); return await GetUserInfoAsync(tokenResponse.AccessToken!, cancellationToken); } public async Task RegisterAsync(RegisterRequest request, CancellationToken cancellationToken) { var adminToken = await RequestAdminTokenAsync(cancellationToken); var userId = await CreateUserAsync(adminToken, request, cancellationToken); await SetPasswordAsync(adminToken, userId, request.Password, cancellationToken); await TryAssignPlayerRoleAsync(adminToken, userId, cancellationToken); return await LoginAsync(request.Username, request.Password, cancellationToken); } private async Task RequestPasswordTokenAsync(string username, string password, CancellationToken cancellationToken) { var formData = new Dictionary { ["client_id"] = _options.ClientId, ["grant_type"] = "password", ["scope"] = "openid profile email", ["username"] = username, ["password"] = password, }; using var response = await _httpClient.PostAsync( $"{GetRealmBaseUrl()}/protocol/openid-connect/token", new FormUrlEncodedContent(formData), cancellationToken); if (!response.IsSuccessStatusCode) { var error = await ReadJsonAsync(response, cancellationToken); var message = error?.ErrorDescription switch { not null when error.ErrorDescription.Contains("Account is not fully set up", StringComparison.OrdinalIgnoreCase) => "Le compte existe mais n'est pas encore actif dans Keycloak.", not null => "Identifiants invalides ou connexion Keycloak indisponible.", _ => "Connexion Keycloak impossible.", }; throw new KeycloakAuthException(message, (int)response.StatusCode); } var token = await ReadJsonAsync(response, cancellationToken); if (token?.AccessToken is null) { throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton utilisable."); } return token; } private async Task RequestAdminTokenAsync(CancellationToken cancellationToken) { var formData = new Dictionary { ["client_id"] = _options.AdminClientId, ["grant_type"] = "password", ["username"] = _options.AdminUsername, ["password"] = _options.AdminPassword, }; using var response = await _httpClient.PostAsync( $"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.AdminRealm)}/protocol/openid-connect/token", new FormUrlEncodedContent(formData), cancellationToken); if (!response.IsSuccessStatusCode) { throw new KeycloakAuthException("Impossible d'obtenir un acces admin Keycloak.", (int)response.StatusCode); } var token = await ReadJsonAsync(response, cancellationToken); if (string.IsNullOrWhiteSpace(token?.AccessToken)) { throw new KeycloakAuthException("Keycloak n'a pas retourne de jeton admin."); } return token.AccessToken; } private async Task GetUserInfoAsync(string accessToken, CancellationToken cancellationToken) { using var request = new HttpRequestMessage(HttpMethod.Get, $"{GetRealmBaseUrl()}/protocol/openid-connect/userinfo"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); using var response = await _httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { throw new KeycloakAuthException("Impossible de recuperer le profil utilisateur Keycloak.", (int)response.StatusCode); } var userInfo = await ReadJsonAsync(response, cancellationToken); if (userInfo is null) { throw new KeycloakAuthException("Le profil Keycloak est invalide."); } userInfo.Roles = ExtractRealmRoles(accessToken); return userInfo; } private async Task CreateUserAsync(string adminToken, RegisterRequest request, CancellationToken cancellationToken) { using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users") { Content = JsonContent.Create(new { username = request.Username.Trim(), email = request.Email.Trim(), enabled = true, emailVerified = false, firstName = string.IsNullOrWhiteSpace(request.FirstName) ? null : request.FirstName.Trim(), lastName = string.IsNullOrWhiteSpace(request.LastName) ? null : request.LastName.Trim(), }, options: JsonOptions) }; httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken); if (response.StatusCode == HttpStatusCode.Conflict) { throw new KeycloakAuthException("Ce nom d'utilisateur ou cet email existe deja.", StatusCodes.Status409Conflict); } if (!response.IsSuccessStatusCode) { throw new KeycloakAuthException("Creation du compte impossible dans Keycloak.", (int)response.StatusCode); } var userId = response.Headers.Location?.Segments.LastOrDefault(); if (!string.IsNullOrWhiteSpace(userId)) { return userId.Trim('/'); } var fallbackUserId = await FindUserIdByUsernameAsync(adminToken, request.Username, cancellationToken); if (!string.IsNullOrWhiteSpace(fallbackUserId)) { return fallbackUserId; } throw new KeycloakAuthException("Le compte a ete cree mais l'identifiant Keycloak est introuvable."); } private async Task SetPasswordAsync(string adminToken, string userId, string password, CancellationToken cancellationToken) { using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/reset-password") { Content = JsonContent.Create(new { type = "password", value = password, temporary = false, }) }; httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken); if (!response.IsSuccessStatusCode) { throw new KeycloakAuthException("Le compte a ete cree mais le mot de passe n'a pas pu etre defini.", (int)response.StatusCode); } } private async Task TryAssignPlayerRoleAsync(string adminToken, string userId, CancellationToken cancellationToken) { using var roleRequest = new HttpRequestMessage(HttpMethod.Get, $"{GetAdminBaseUrl()}/roles/player"); roleRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); using var roleResponse = await _httpClient.SendAsync(roleRequest, cancellationToken); if (!roleResponse.IsSuccessStatusCode) { return; } var role = await ReadJsonAsync(roleResponse, cancellationToken); if (role?.Name is null) { return; } using var assignRequest = new HttpRequestMessage(HttpMethod.Post, $"{GetAdminBaseUrl()}/users/{Uri.EscapeDataString(userId)}/role-mappings/realm") { Content = JsonContent.Create(new[] { new RealmRoleRepresentation { Id = role.Id, Name = role.Name, Description = role.Description, } }) }; assignRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); using var assignResponse = await _httpClient.SendAsync(assignRequest, cancellationToken); _ = assignResponse.IsSuccessStatusCode; } private async Task FindUserIdByUsernameAsync(string adminToken, string username, CancellationToken cancellationToken) { using var httpRequest = new HttpRequestMessage( HttpMethod.Get, $"{GetAdminBaseUrl()}/users?username={Uri.EscapeDataString(username)}&exact=true"); httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); using var response = await _httpClient.SendAsync(httpRequest, cancellationToken); if (!response.IsSuccessStatusCode) { return null; } var users = await ReadJsonAsync>(response, cancellationToken); return users?.FirstOrDefault()?.Id; } private string[] ExtractRealmRoles(string accessToken) { try { var tokenParts = accessToken.Split('.'); if (tokenParts.Length < 2) { return []; } var payloadBytes = DecodeBase64Url(tokenParts[1]); using var document = JsonDocument.Parse(payloadBytes); if (!document.RootElement.TryGetProperty("realm_access", out var realmAccess) || !realmAccess.TryGetProperty("roles", out var rolesElement) || rolesElement.ValueKind != JsonValueKind.Array) { return []; } return rolesElement .EnumerateArray() .Select(role => role.GetString()) .Where(role => !string.IsNullOrWhiteSpace(role)) .Cast() .ToArray(); } catch { return []; } } private static byte[] DecodeBase64Url(string input) { var normalized = input.Replace('-', '+').Replace('_', '/'); normalized = normalized.PadRight(normalized.Length + (4 - normalized.Length % 4) % 4, '='); return Convert.FromBase64String(normalized); } private string GetBaseUrl() => _options.BaseUrl.TrimEnd('/'); private string GetRealmBaseUrl() => $"{GetBaseUrl()}/realms/{Uri.EscapeDataString(_options.Realm)}"; private string GetAdminBaseUrl() => $"{GetBaseUrl()}/admin/realms/{Uri.EscapeDataString(_options.Realm)}"; private static async Task ReadJsonAsync(HttpResponseMessage response, CancellationToken cancellationToken) { var content = await response.Content.ReadAsStringAsync(cancellationToken); if (string.IsNullOrWhiteSpace(content)) { return default; } return JsonSerializer.Deserialize(content, JsonOptions); } private sealed class TokenSuccessResponse { [JsonPropertyName("access_token")] public string? AccessToken { get; init; } } private sealed class TokenErrorResponse { [JsonPropertyName("error")] public string? Error { get; init; } [JsonPropertyName("error_description")] public string? ErrorDescription { get; init; } } private sealed class UserRepresentation { [JsonPropertyName("id")] public string? Id { get; init; } } private sealed class RealmRoleRepresentation { [JsonPropertyName("id")] public string? Id { get; init; } [JsonPropertyName("name")] public string? Name { get; init; } [JsonPropertyName("description")] public string? Description { get; init; } } } public sealed class KeycloakAuthException(string message, int statusCode = StatusCodes.Status400BadRequest) : Exception(message) { public int StatusCode { get; } = statusCode; }