using System.Collections.Concurrent; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text.Json; using Tiku.Api.Contracts; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; namespace Tiku.IntegrationTests.Api; internal sealed record TestAuthenticationTokens(string AccessToken, string RefreshToken); internal static class AuthenticationTestClientExtensions { private static readonly ConcurrentDictionary AuthenticatorKeys = new(StringComparer.Ordinal); public static async Task LoginAsTenantAsync( this HttpClient client, Guid tenantId, string identifier, string password = PasswordTestUserExtensions.TestPassword) { SetTenantHeader(client, tenantId); var response = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { Realm = AuthRealm.Tenant, TenantCode = tenantId.ToString("N"), Identifier = identifier, Password = password }); return await client.CompleteTenantAuthenticationAsync(response, tenantId, identifier); } public static async Task CompleteTenantAuthenticationAsync( this HttpClient client, HttpResponseMessage response, Guid tenantId, string authenticatorCacheKey) { SetTenantHeader(client, tenantId); using var authentication = await ReadSuccessfulJsonAsync(response); var root = authentication.RootElement; var status = root.GetProperty("status").GetString(); if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase)) { return ReadTokens(root.GetProperty("user").GetProperty("tokens")); } var challengeToken = root.GetProperty("challengeToken").GetString() ?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token."); var keyId = $"{tenantId:N}:{authenticatorCacheKey}"; if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase)) { var setupResponse = await client.PostAsJsonAsync( "/api/auth/mfa/totp/setup", new MfaChallengeDto { ChallengeToken = challengeToken }); using var setup = await ReadSuccessfulJsonAsync(setupResponse); var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString() ?? throw new InvalidOperationException("MFA setup did not return a shared key."); AuthenticatorKeys[keyId] = sharedKey; var confirmResponse = await client.PostAsJsonAsync( "/api/auth/mfa/totp/confirm", new MfaChallengeDto { ChallengeToken = challengeToken, Code = GenerateTotp(sharedKey) }); using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse); return ReadTokens( confirmation.RootElement .GetProperty("authentication") .GetProperty("user") .GetProperty("tokens")); } if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) && AuthenticatorKeys.TryGetValue(keyId, out var existingKey)) { var verifyResponse = await client.PostAsJsonAsync( "/api/auth/mfa/totp/verify", new MfaChallengeDto { ChallengeToken = challengeToken, Code = GenerateTotp(existingKey) }); using var verification = await ReadSuccessfulJsonAsync(verifyResponse); return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens")); } throw new InvalidOperationException($"Unsupported test authentication status '{status}'."); } public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens) { client.DefaultRequestHeaders.Authorization = new("Bearer", tokens.AccessToken); } private static void SetTenantHeader(HttpClient client, Guid tenantId) { client.DefaultRequestHeaders.Remove("x-tenant-code"); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); } private static async Task ReadSuccessfulJsonAsync(HttpResponseMessage response) { var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new HttpRequestException( $"Authentication request failed with {(int)response.StatusCode} ({response.StatusCode}): {body}"); } return JsonDocument.Parse(body); } private static TestAuthenticationTokens ReadTokens(JsonElement tokens) { var accessToken = tokens.GetProperty("accessToken").GetString() ?? throw new InvalidOperationException("Authentication response did not contain an access token."); var refreshToken = tokens.GetProperty("refreshToken").GetString() ?? throw new InvalidOperationException("Authentication response did not contain a refresh token."); return new TestAuthenticationTokens(accessToken, refreshToken); } internal static string GenerateTotp(string sharedKey) { var secret = DecodeBase32(sharedKey); var counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30; Span counterBytes = stackalloc byte[8]; for (var index = counterBytes.Length - 1; index >= 0; index--) { counterBytes[index] = (byte)(counter & 0xff); counter >>= 8; } var hash = HMACSHA1.HashData(secret, counterBytes); var offset = hash[^1] & 0x0f; var binaryCode = ((hash[offset] & 0x7f) << 24) | (hash[offset + 1] << 16) | (hash[offset + 2] << 8) | hash[offset + 3]; return (binaryCode % 1_000_000).ToString("D6", System.Globalization.CultureInfo.InvariantCulture); } private static byte[] DecodeBase32(string value) { var normalized = value.Replace(" ", string.Empty, StringComparison.Ordinal) .TrimEnd('=') .ToUpperInvariant(); var output = new byte[normalized.Length * 5 / 8]; var buffer = 0; var bitsInBuffer = 0; var outputIndex = 0; foreach (var character in normalized) { var digit = character switch { >= 'A' and <= 'Z' => character - 'A', >= '2' and <= '7' => character - '2' + 26, _ => throw new FormatException("Authenticator shared key is not valid Base32.") }; buffer = (buffer << 5) | digit; bitsInBuffer += 5; if (bitsInBuffer < 8) { continue; } output[outputIndex++] = (byte)(buffer >> (bitsInBuffer - 8)); bitsInBuffer -= 8; buffer &= (1 << bitsInBuffer) - 1; } return output; } }