books/backend/Books.Api/Banking/EnableBankingClient.cs

400 lines
15 KiB
C#
Raw Normal View History

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<EnableBankingClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly RSA _rsaKey;
private readonly SigningCredentials _signingCredentials;
public EnableBankingClient(
HttpClient httpClient,
EnableBankingOptions options,
ILogger<EnableBankingClient> 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<IReadOnlyList<Aspsp>> 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<AspspsApiResponse>(_jsonOptions, ct);
return result?.Aspsps?.Select(MapAspsp).ToList() ?? [];
}
public async Task<AuthorizationResponse> 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<AuthApiResponse>(_jsonOptions, ct);
return new AuthorizationResponse(result!.AuthorizationId, result.Url);
}
public async Task<SessionResponse> 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
var rawJson = await response.Content.ReadAsStringAsync(ct);
_logger.LogInformation("Raw session response: {RawJson}", rawJson);
var result = System.Text.Json.JsonSerializer.Deserialize<SessionApiResponse>(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<AccountDetails> 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<AccountDetailsApiResponse>(_jsonOptions, ct);
return new AccountDetails(
accountId,
result?.Iban ?? "",
result?.Currency ?? "DKK",
result?.Name,
result?.OwnerName,
result?.Product);
}
public async Task<IReadOnlyList<Balance>> 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<BalancesApiResponse>(_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<TransactionsResponse> 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.LogInformation("Transactions API response ({Status}): {RawJson}", response.StatusCode, rawJson);
response.EnsureSuccessStatusCode();
var result = JsonSerializer.Deserialize<TransactionsApiResponse>(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<Claim>? 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<AspspApiModel>? Aspsps);
private record AspspApiModel(string? Name, string? Country, string? Logo, List<string>? PsuTypes);
private record AuthApiResponse(string AuthorizationId, string Url);
private record AccessApiModel(DateTimeOffset? ValidUntil);
private record SessionApiResponse(
string SessionId,
AspspApiModel? Aspsp,
List<AccountApiModel>? 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<BalanceApiModel>? 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<decimal>
{
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<TransactionApiModel>? Transactions,
string? ContinuationKey);
private record TransactionApiModel(
string? TransactionId,
string? EntryReference,
AmountApiModel? TransactionAmount,
DateOnly? BookingDate,
DateOnly? ValueDate,
PartyApiModel? Creditor,
PartyApiModel? Debtor,
List<string>? 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; } = "";
}