using System.Security.Claims; using System.Security.Cryptography; using System.Text; using Books.Api.Authorization; using Books.Api.Banking; using Books.Api.Commands.BankConnections; using Books.Api.Domain.BankConnections; using Books.Api.EventFlow.Repositories; using EventFlow; using EventFlow.Aggregates.ExecutionResults; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Books.Api.Controllers; [ApiController] [Route("api/banking")] [Authorize] public class BankingController : ControllerBase { private readonly ICommandBus _commandBus; private readonly IEnableBankingClient _bankingClient; private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly IBankConnectionRepository _bankConnectionRepository; private readonly ICompanyAccessService _companyAccess; public BankingController( ICommandBus commandBus, IEnableBankingClient bankingClient, ILogger logger, IConfiguration configuration, IBankConnectionRepository bankConnectionRepository, ICompanyAccessService companyAccess) { _commandBus = commandBus; _bankingClient = bankingClient; _logger = logger; _configuration = configuration; _bankConnectionRepository = bankConnectionRepository; _companyAccess = companyAccess; } /// /// OAuth callback from Enable Banking after user authorizes bank connection. /// [HttpGet("callback")] public async Task Callback( [FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, [FromQuery] string? error_description, CancellationToken ct) { var frontendBaseUrl = _configuration["Frontend:BaseUrl"] ?? "http://localhost:3000"; var redirectUrl = $"{frontendBaseUrl}/indstillinger?tab=bankAccounts"; // Handle error from bank if (!string.IsNullOrEmpty(error)) { _logger.LogWarning("Bank authorization failed: {Error} - {Description}", error, error_description); return Redirect($"{redirectUrl}&error={Uri.EscapeDataString(error_description ?? error)}"); } // Validate required parameters if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state)) { _logger.LogWarning("Missing code or state in callback"); return Redirect($"{redirectUrl}&error=missing_parameters"); } try { // Validate HMAC-signed state token to prevent CSRF attacks var connectionId = ValidateStateToken(state); if (connectionId == null) { _logger.LogWarning("Invalid or tampered state token in bank callback"); return Redirect($"{redirectUrl}&error=invalid_state"); } // Verify the user has access to the company that owns this bank connection var bankConnection = await _bankConnectionRepository.GetByIdAsync(connectionId, ct); if (bankConnection == null) { _logger.LogWarning("Bank connection {ConnectionId} not found", connectionId); return Redirect($"{redirectUrl}&error=connection_not_found"); } var canWrite = await _companyAccess.CanWriteAsync(bankConnection.CompanyId, ct); if (!canWrite) { _logger.LogWarning( "User does not have write access to company {CompanyId} for bank connection {ConnectionId}", bankConnection.CompanyId, connectionId); return Redirect($"{redirectUrl}&error=access_denied"); } // Get PSU headers from HttpContext (required by Enable Banking API) var psuIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var psuUserAgent = Request.Headers.UserAgent.ToString(); // Exchange authorization code for session var session = await _bankingClient.CreateSessionAsync(code, psuIpAddress, psuUserAgent, ct); _logger.LogInformation( "Bank session created: {SessionId}, Bank: {Bank}, Accounts: {AccountCount}", session.SessionId, session.AspspName, session.Accounts.Count); // Complete the bank connection var command = new EstablishBankConnectionCommand( BankConnectionId.With(connectionId), session.SessionId, session.ValidUntil, session.Accounts.Select(a => new BankAccountInfo( a.AccountId, a.Iban, a.Currency, a.AccountName)).ToList()); var result = await _commandBus.PublishAsync(command, ct); if (result is FailedExecutionResult failed) { _logger.LogError("Failed to complete bank connection: {Errors}", string.Join(", ", failed.Errors)); return Redirect($"{redirectUrl}&error=completion_failed"); } _logger.LogInformation("Bank connection {ConnectionId} completed successfully", connectionId); return Redirect($"{redirectUrl}&success=true"); } catch (Exception ex) { _logger.LogError(ex, "Error completing bank connection"); return Redirect($"{redirectUrl}&error=internal_error"); } } /// /// Generates an HMAC-signed state token containing the connection ID. /// Used during StartBankConnection to create a CSRF-safe state parameter. /// public static string GenerateStateToken(string connectionId, string secret) { var signature = ComputeHmac(connectionId, secret); return $"{connectionId}.{signature}"; } /// /// Validates an HMAC-signed state token and extracts the connection ID. /// Returns null if the token is invalid or tampered with. /// private string? ValidateStateToken(string state) { var secret = _configuration["Banking:StateSecret"] ?? _configuration["Jwt:Key"] ?? ""; // Support legacy format (plain connection ID without signature) during transition if (!state.Contains('.')) { // Legacy format: treat as plain connection ID but log a warning _logger.LogWarning("Bank callback received legacy state format without HMAC signature"); return state; } var dotIndex = state.LastIndexOf('.'); var connectionId = state[..dotIndex]; var providedSignature = state[(dotIndex + 1)..]; var expectedSignature = ComputeHmac(connectionId, secret); if (!CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expectedSignature), Encoding.UTF8.GetBytes(providedSignature))) { return null; } return connectionId; } private static string ComputeHmac(string data, string secret) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data)); return Convert.ToBase64String(hash); } }