using System.Net; using System.Text.Json.Nodes; using Csob.Client; using Csob.Configuration; using Csob.Credentials; using Csob.Models; namespace Csob.Services; /// /// OAuth2 Authorization Code helper for the ČSOB PSD2 flow. Builds the PSU authorization URL and /// exchanges/refreshes tokens against the ČSOB token endpoint. The token endpoint is behind mutual /// TLS, so the client certificate header is required for the token/refresh calls. Client id/secret /// are taken from per-request headers (this service is multi-tenant and stores no app credentials). /// public sealed class OAuthService { private readonly RequestCredentialsProvider _credentials; private readonly CsobHttpClientProvider _httpClientProvider; private readonly CsobSettings _settings; public OAuthService( RequestCredentialsProvider credentials, CsobHttpClientProvider httpClientProvider, CsobSettings settings) { _credentials = credentials; _httpClientProvider = httpClientProvider; _settings = settings; } /// Builds the authorization URL to which the PSU must be redirected. public AuthorizationUrlResponse BuildAuthorizationUrl(string redirectUri, string? scope, string? state) { var clientId = RequireHeader(CredentialConstants.ClientIdHeader); var query = new Dictionary { ["response_type"] = "code", ["client_id"] = clientId, ["redirect_uri"] = redirectUri, ["scope"] = scope, ["state"] = state, }; var url = _settings.OAuthAuthorizeUrl + "?" + string.Join("&", query .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value)) .Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}")); return new AuthorizationUrlResponse { AuthorizationUrl = url, State = state }; } public Task ExchangeCodeAsync(TokenExchangeRequest request, CancellationToken ct) => PostTokenAsync(new Dictionary { ["grant_type"] = "authorization_code", ["code"] = request.Code, ["redirect_uri"] = request.RedirectUri, }, ct); public Task RefreshAsync(TokenRefreshRequest request, CancellationToken ct) => PostTokenAsync(new Dictionary { ["grant_type"] = "refresh_token", ["refresh_token"] = request.RefreshToken, }, ct); private async Task PostTokenAsync(Dictionary form, CancellationToken ct) { var clientId = RequireHeader(CredentialConstants.ClientIdHeader); var clientSecret = RequireHeader(CredentialConstants.ClientSecretHeader); var certificate = _credentials.TryBuildCertificate() ?? throw new MissingCredentialsException(new[] { CredentialConstants.CertificateHeader }); form["client_id"] = clientId; form["client_secret"] = clientSecret; var http = _httpClientProvider.GetClient(certificate); using var content = new FormUrlEncodedContent(form); using var response = await http.PostAsync(_settings.OAuthTokenUrl, content, ct); var payload = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { throw new CsobApiException(response.StatusCode, ParseOAuthError(payload), payload); } return string.IsNullOrWhiteSpace(payload) ? null : JsonNode.Parse(payload); } private string RequireHeader(string name) => _credentials.Header(name) ?? throw new MissingCredentialsException(new[] { name }); private static IReadOnlyList ParseOAuthError(string payload) { if (string.IsNullOrWhiteSpace(payload)) { return Array.Empty(); } try { // OAuth2 errors use { "error": "...", "error_description": "..." }. var error = JsonNode.Parse(payload)?["error"]?.GetValue(); return string.IsNullOrWhiteSpace(error) ? Array.Empty() : new[] { error! }; } catch (System.Text.Json.JsonException) { return Array.Empty(); } } }