using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Json; using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.IdentityModel.Tokens; namespace Books.Api.Banking; public class EnableBankingClient : IEnableBankingClient, IDisposable { private readonly HttpClient _httpClient; private readonly EnableBankingOptions _options; private readonly ILogger _logger; private readonly JsonSerializerOptions _jsonOptions; private readonly RSA _rsaKey; private readonly SigningCredentials _signingCredentials; public EnableBankingClient( HttpClient httpClient, EnableBankingOptions options, ILogger logger) { _httpClient = httpClient; _options = options; _logger = logger; _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; // Parse RSA key once at startup and cache it _rsaKey = RSA.Create(); if (!string.IsNullOrEmpty(options.PrivateKey)) { _rsaKey.ImportFromPem(options.PrivateKey); _signingCredentials = new SigningCredentials( new RsaSecurityKey(_rsaKey) { KeyId = options.KeyId }, SecurityAlgorithms.RsaSha256) { CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false } }; } else { _signingCredentials = null!; } } public void Dispose() { _rsaKey?.Dispose(); } public async Task> GetAspspsAsync(string country = "DK", CancellationToken ct = default) { var request = new HttpRequestMessage(HttpMethod.Get, $"/aspsps?country={country}"); AddAuthHeader(request); var response = await _httpClient.SendAsync(request, ct); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(_jsonOptions, ct); return result?.Aspsps?.Select(MapAspsp).ToList() ?? []; } public async Task StartAuthorizationAsync( string aspspName, string redirectUrl, string state, string psuType = "personal", string? psuIpAddress = null, string? psuUserAgent = null, CancellationToken ct = default) { var request = new HttpRequestMessage(HttpMethod.Post, "/auth"); AddAuthHeader(request); AddPsuHeaders(request, psuIpAddress, psuUserAgent); var body = new { access = new { valid_until = DateTimeOffset.UtcNow.AddDays(90).ToString("o") }, aspsp = new { name = aspspName, country = "DK" }, state, redirect_url = redirectUrl, psu_type = psuType }; request.Content = JsonContent.Create(body, options: _jsonOptions); _logger.LogDebug( "Starting authorization for {Bank} with PSU IP: {PsuIp}, PSU UA: {PsuUa}", aspspName, psuIpAddress ?? "(not provided)", psuUserAgent ?? "(not provided)"); var response = await _httpClient.SendAsync(request, ct); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(ct); throw new HttpRequestException($"Enable Banking auth error {response.StatusCode}: {errorContent}"); } var result = await response.Content.ReadFromJsonAsync(_jsonOptions, ct); return new AuthorizationResponse(result!.AuthorizationId, result.Url); } public async Task CreateSessionAsync( string authorizationCode, string? psuIpAddress = null, string? psuUserAgent = null, CancellationToken ct = default) { var request = new HttpRequestMessage(HttpMethod.Post, "/sessions"); AddAuthHeader(request); AddPsuHeaders(request, psuIpAddress, psuUserAgent); _logger.LogDebug( "Creating session with PSU IP: {PsuIp}, PSU UA: {PsuUa}", psuIpAddress ?? "(not provided)", psuUserAgent ?? "(not provided)"); request.Content = JsonContent.Create(new { code = authorizationCode }, options: _jsonOptions); var response = await _httpClient.SendAsync(request, ct); response.EnsureSuccessStatusCode(); // Log raw response for debugging (sensitive - only at Debug level) var rawJson = await response.Content.ReadAsStringAsync(ct); _logger.LogDebug("Raw session response: {ResponseLength} chars", rawJson.Length); var result = System.Text.Json.JsonSerializer.Deserialize(rawJson, _jsonOptions); var sessionId = result!.SessionId; var aspspName = result.Aspsp?.Name ?? ""; _logger.LogDebug( "Session created: {SessionId}, Bank: {Bank}, Accounts in response: {AccountCount}", sessionId, aspspName, result.Accounts?.Count ?? 0); // Map accounts from session response // Use Uid as AccountId - this is required for fetching transactions/balances // Filter out accounts without uid as they cannot be used for API calls var accounts = result.Accounts? .Where(a => !string.IsNullOrEmpty(a.Uid)) .Select(a => new SessionAccount( a.Uid!, a.AccountId?.Iban ?? "", a.Currency ?? "DKK", a.Name)).ToList() ?? []; _logger.LogDebug( "Mapped {MappedCount} accounts with valid uid from {TotalCount} accounts in response", accounts.Count, result.Accounts?.Count ?? 0); // Note: There is NO separate /accounts endpoint in Enable Banking API // The session response is the ONLY source for account information // If accounts array is empty, it may be because: // 1. Wrong psuType (personal vs business) // 2. Bank doesn't provide account list for this connection type if (accounts.Count == 0) { _logger.LogWarning( "No accounts with valid uid in session response for {Bank}. " + "This may indicate wrong psuType or bank limitation. Session: {SessionId}", aspspName, sessionId); } // valid_until is nested inside the access object in the API response var validUntil = result.Access?.ValidUntil ?? DateTimeOffset.UtcNow.AddDays(90); return new SessionResponse(sessionId, aspspName, accounts, validUntil); } public async Task GetAccountDetailsAsync(string sessionId, string accountId, CancellationToken ct = default) { var request = new HttpRequestMessage(HttpMethod.Get, $"/accounts/{accountId}/details"); AddAuthHeader(request, sessionId); var response = await _httpClient.SendAsync(request, ct); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(_jsonOptions, ct); return new AccountDetails( accountId, result?.Iban ?? "", result?.Currency ?? "DKK", result?.Name, result?.OwnerName, result?.Product); } public async Task> GetBalancesAsync(string sessionId, string accountId, CancellationToken ct = default) { var request = new HttpRequestMessage(HttpMethod.Get, $"/accounts/{accountId}/balances"); AddAuthHeader(request, sessionId); var response = await _httpClient.SendAsync(request, ct); response.EnsureSuccessStatusCode(); var result = await response.Content.ReadFromJsonAsync(_jsonOptions, ct); return result?.Balances?.Select(b => new Balance( b.BalanceType ?? "", b.BalanceAmount?.Amount ?? 0, b.BalanceAmount?.Currency ?? "DKK", b.ReferenceDate)).ToList() ?? []; } public async Task GetTransactionsAsync( string sessionId, string accountId, DateOnly? dateFrom = null, DateOnly? dateTo = null, CancellationToken ct = default) { var from = dateFrom ?? DateOnly.FromDateTime(DateTime.Today.AddMonths(-3)); var to = dateTo ?? DateOnly.FromDateTime(DateTime.Today); var url = $"/accounts/{accountId}/transactions?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}"; var request = new HttpRequestMessage(HttpMethod.Get, url); AddAuthHeader(request, sessionId); var response = await _httpClient.SendAsync(request, ct); var rawJson = await response.Content.ReadAsStringAsync(ct); _logger.LogDebug("Transactions API response ({Status}): {ResponseLength} chars", response.StatusCode, rawJson.Length); response.EnsureSuccessStatusCode(); var result = JsonSerializer.Deserialize(rawJson, _jsonOptions); return new TransactionsResponse( result?.Transactions?.Select(t => new Transaction( t.TransactionId ?? t.EntryReference ?? GenerateDeterministicId(t), t.IsDebit ? -(t.TransactionAmount?.Amount ?? 0) : (t.TransactionAmount?.Amount ?? 0), t.TransactionAmount?.Currency ?? "DKK", t.BookingDate ?? DateOnly.FromDateTime(DateTime.Today), t.ValueDate, t.CreditorName, t.DebtorName, t.RemittanceInformationUnstructured, t.EndToEndId, t.IsDebit)).ToList() ?? [], result?.ContinuationKey); } private static string GenerateDeterministicId(TransactionApiModel t) { // Create a deterministic ID based on transaction content // Used when bank doesn't provide a unique ID var content = string.Join("|", t.TransactionAmount?.Amount.ToString(System.Globalization.CultureInfo.InvariantCulture), t.TransactionAmount?.Currency, t.BookingDate?.ToString("yyyy-MM-dd"), t.RemittanceInformationUnstructured?.Trim(), t.CreditorName?.Trim(), t.DebtorName?.Trim()); using var sha256 = System.Security.Cryptography.SHA256.Create(); var bytes = System.Text.Encoding.UTF8.GetBytes(content); var hash = sha256.ComputeHash(bytes); return Convert.ToHexString(hash).ToLowerInvariant(); } private void AddAuthHeader(HttpRequestMessage request, string? sessionId = null) { var jwt = GenerateJwt(sessionId); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt); } private void AddPsuHeaders(HttpRequestMessage request, string? psuIpAddress, string? psuUserAgent) { if (!string.IsNullOrEmpty(psuIpAddress)) request.Headers.Add("psu-ip-address", psuIpAddress); if (!string.IsNullOrEmpty(psuUserAgent)) request.Headers.Add("psu-user-agent", psuUserAgent); } private string GenerateJwt(string? sessionId = null) { var now = DateTime.UtcNow; // Build claims list - only add access claim if session is provided List? claims = null; if (!string.IsNullOrEmpty(sessionId)) { claims = [new Claim("access", JsonSerializer.Serialize(new { session_id = sessionId }))]; } // Create header with kid explicitly set var header = new JwtHeader(_signingCredentials) { ["kid"] = _options.ApplicationId }; // Create payload matching Enable Banking's expected format var payload = new JwtPayload( issuer: "enablebanking.com", audience: "api.enablebanking.com", claims: claims, notBefore: null, expires: now.AddHours(1), issuedAt: now); var token = new JwtSecurityToken(header, payload); return new JwtSecurityTokenHandler().WriteToken(token); } private static Aspsp MapAspsp(AspspApiModel a) => new( a.Name ?? "", a.Country ?? "", a.Logo ?? "", a.PsuTypes ?? [], a.PsuTypes?.Contains("business") ?? false, a.PsuTypes?.Contains("personal") ?? false); // API Response models private record AspspsApiResponse(List? Aspsps); private record AspspApiModel(string? Name, string? Country, string? Logo, List? PsuTypes); private record AuthApiResponse(string AuthorizationId, string Url); private record AccessApiModel(DateTimeOffset? ValidUntil); private record SessionApiResponse( string SessionId, AspspApiModel? Aspsp, List? Accounts, AccessApiModel? Access); private record AccountApiModel( AccountIdApiModel? AccountId, string? Uid, string? Currency, string? Name); private record AccountIdApiModel(string? Iban); private record AccountDetailsApiResponse( string? Iban, string? Currency, string? Name, string? OwnerName, string? Product); private record BalancesApiResponse(List? Balances); private record BalanceApiModel( string? BalanceType, AmountApiModel? BalanceAmount, DateTimeOffset? ReferenceDate); private record AmountApiModel( [property: JsonConverter(typeof(StringToDecimalConverter))] decimal Amount, string? Currency); private class StringToDecimalConverter : JsonConverter { public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.String) { var str = reader.GetString(); return decimal.Parse(str!, System.Globalization.CultureInfo.InvariantCulture); } return reader.GetDecimal(); } public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) { writer.WriteNumberValue(value); } } private record TransactionsApiResponse( List? Transactions, string? ContinuationKey); private record TransactionApiModel( string? TransactionId, string? EntryReference, AmountApiModel? TransactionAmount, DateOnly? BookingDate, DateOnly? ValueDate, PartyApiModel? Creditor, PartyApiModel? Debtor, List? RemittanceInformation, string? EndToEndId, string? CreditDebitIndicator) { public string? CreditorName => Creditor?.Name; public string? DebtorName => Debtor?.Name; public string? RemittanceInformationUnstructured => RemittanceInformation != null && RemittanceInformation.Count > 0 ? string.Join(" ", RemittanceInformation) : null; public bool IsDebit => CreditDebitIndicator == "DBIT"; } private record PartyApiModel(string? Name); } public class EnableBankingOptions { public string ApplicationId { get; set; } = ""; public string KeyId { get; set; } = ""; public string PrivateKey { get; set; } = ""; }