Files
tiku-backend.net/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs

263 lines
10 KiB
C#

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<string, string> AuthenticatorKeys = new(StringComparer.Ordinal);
public static async Task<TestAuthenticationTokens> 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<TestAuthenticationTokens> LoginAsPlatformAsync(
this HttpClient client,
string identifier,
string password = PasswordTestUserExtensions.TestPassword)
{
client.DefaultRequestHeaders.Remove("x-tenant-code");
var response = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
Realm = AuthRealm.Platform,
Identifier = identifier,
Password = password
});
return await client.CompletePlatformAuthenticationAsync(response, identifier);
}
public static async Task<TestAuthenticationTokens> 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}'.");
}
private static async Task<TestAuthenticationTokens> CompletePlatformAuthenticationAsync(
this HttpClient client,
HttpResponseMessage response,
string authenticatorCacheKey)
{
client.DefaultRequestHeaders.Remove("x-tenant-code");
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 = $"platform:{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<JsonDocument> 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<byte> 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;
}
}