using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; namespace IronServices.Client; /// /// Client for IronServices APIs with session-based authentication. /// Handles login, token storage, and automatic bearer token attachment across multiple services. /// public class IronServicesClient : IDisposable { private readonly HttpClient _httpClient; private readonly ITokenStorage _tokenStorage; private readonly JsonSerializerOptions _jsonOptions; private readonly IronServicesClientOptions _options; private string? _sessionToken; private DateTime? _expiresAt; private bool _disposed; // Lazy-initialized API services private LicensingApi? _licensing; private NotifyApi? _notify; private TelemetryApi? _telemetry; /// /// IronLicensing API operations. /// public LicensingApi Licensing => _licensing ??= new LicensingApi(this, _options.LicensingUrl); /// /// IronNotify API operations. /// public NotifyApi Notify => _notify ??= new NotifyApi(this, _options.NotifyUrl); /// /// IronTelemetry API operations. /// public TelemetryApi Telemetry => _telemetry ??= new TelemetryApi(this, _options.TelemetryUrl); /// /// Creates a new IronServices client with multiple service URLs. /// public IronServicesClient(IronServicesClientOptions options, ITokenStorage? tokenStorage = null) { _options = options ?? throw new ArgumentNullException(nameof(options)); _httpClient = new HttpClient(); _httpClient.DefaultRequestHeaders.Add("Accept", "application/json"); _tokenStorage = tokenStorage ?? new InMemoryTokenStorage(); _jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; // Try to restore session from storage RestoreSession(); } /// /// Creates a new IronServices client with a single base URL (legacy compatibility). /// All services will use this URL. /// public IronServicesClient(string baseUrl, ITokenStorage? tokenStorage = null) : this(new IronServicesClientOptions { LicensingUrl = baseUrl, NotifyUrl = baseUrl, TelemetryUrl = baseUrl }, tokenStorage) { } /// /// Whether the client has a valid session token. /// public bool IsAuthenticated => !string.IsNullOrEmpty(_sessionToken) && (_expiresAt == null || _expiresAt > DateTime.UtcNow); /// /// Current session expiration time (UTC). /// public DateTime? SessionExpiresAt => _expiresAt; /// /// The current session token (for advanced scenarios). /// public string? SessionToken => _sessionToken; /// /// Login with email and password. /// public async Task LoginAsync(string email, string password, CancellationToken cancellationToken = default) { var request = new { email, password }; var url = BuildUrl(_options.LicensingUrl, "api/v1/users/login"); var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); if (!response.IsSuccessStatusCode) { var error = await ReadErrorAsync(response, cancellationToken); return new LoginResult { Success = false, Error = error }; } var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); if (result == null || string.IsNullOrEmpty(result.SessionToken)) { return new LoginResult { Success = false, Error = "Invalid response from server" }; } // Store session await SetSessionAsync(result.SessionToken, result.ExpiresAt); return new LoginResult { Success = true, UserId = result.UserId, Email = result.Email, DisplayName = result.DisplayName, Role = result.Role, ExpiresAt = result.ExpiresAt }; } /// /// Register a new account. /// public async Task RegisterAsync(string email, string password, string? displayName = null, CancellationToken cancellationToken = default) { var request = new { email, password, displayName }; var url = BuildUrl(_options.LicensingUrl, "api/v1/users/register"); var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); if (!response.IsSuccessStatusCode) { var error = await ReadErrorAsync(response, cancellationToken); return new RegisterResult { Success = false, Error = error }; } var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); return new RegisterResult { Success = true, UserId = result?.User?.Id, Email = result?.User?.Email, ApiKey = result?.ApiKey }; } /// /// Logout and clear session. /// public async Task LogoutAsync(CancellationToken cancellationToken = default) { if (IsAuthenticated) { try { var url = BuildUrl(_options.LicensingUrl, "api/v1/users/logout"); await _httpClient.PostAsync(url, null, cancellationToken); } catch { // Ignore logout errors } } await ClearSessionAsync(); } /// /// Get current user profile. /// public async Task GetProfileAsync(CancellationToken cancellationToken = default) { EnsureAuthenticated(); var url = BuildUrl(_options.LicensingUrl, "api/v1/users/me"); var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { await ClearSessionAsync(); } return null; } return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); } /// /// Verify email with 6-digit code. /// public async Task VerifyEmailAsync(string email, string code, CancellationToken cancellationToken = default) { var request = new { email, code }; var url = BuildUrl(_options.LicensingUrl, "api/v1/users/verify-email"); var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); if (!response.IsSuccessStatusCode) { var error = await ReadErrorAsync(response, cancellationToken); return new VerifyEmailResult { Success = false, Error = error }; } var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); if (result?.SessionToken != null) { await SetSessionAsync(result.SessionToken, null); } return new VerifyEmailResult { Success = true, Message = result?.Message ?? "Email verified" }; } /// /// Request password reset email. /// public async Task RequestPasswordResetAsync(string email, CancellationToken cancellationToken = default) { var request = new { email }; var url = BuildUrl(_options.LicensingUrl, "api/v1/users/forgot-password"); var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); return response.IsSuccessStatusCode; } /// /// Reset password with token. /// public async Task ResetPasswordAsync(string token, string newPassword, CancellationToken cancellationToken = default) { var request = new { token, newPassword }; var url = BuildUrl(_options.LicensingUrl, "api/v1/users/reset-password"); var response = await _httpClient.PostAsJsonAsync(url, request, _jsonOptions, cancellationToken); if (!response.IsSuccessStatusCode) { var error = await ReadErrorAsync(response, cancellationToken); return new ResetPasswordResult { Success = false, Error = error }; } return new ResetPasswordResult { Success = true }; } /// /// Make an authenticated GET request to a specific URL. /// public async Task GetAsync(string fullUrl, CancellationToken cancellationToken = default) { EnsureAuthenticated(); var response = await _httpClient.GetAsync(fullUrl, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); throw new HttpRequestException($"GET {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}"); } return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); } /// /// Make an authenticated POST request to a specific URL. /// public async Task PostAsync(string fullUrl, TRequest data, CancellationToken cancellationToken = default) { EnsureAuthenticated(); var response = await _httpClient.PostAsJsonAsync(fullUrl, data, _jsonOptions, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); throw new HttpRequestException($"POST {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}"); } return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); } /// /// Make an authenticated PUT request to a specific URL. /// public async Task PutAsync(string fullUrl, TRequest data, CancellationToken cancellationToken = default) { EnsureAuthenticated(); var response = await _httpClient.PutAsJsonAsync(fullUrl, data, _jsonOptions, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); throw new HttpRequestException($"PUT {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}"); } return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken); } /// /// Make an authenticated DELETE request to a specific URL. /// public async Task DeleteAsync(string fullUrl, CancellationToken cancellationToken = default) { EnsureAuthenticated(); var response = await _httpClient.DeleteAsync(fullUrl, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); throw new HttpRequestException($"DELETE {fullUrl} failed with {(int)response.StatusCode} {response.ReasonPhrase}: {errorContent}"); } } /// /// Build a full URL from a base URL and endpoint. /// internal static string BuildUrl(string baseUrl, string endpoint) { return baseUrl.TrimEnd('/') + "/" + endpoint.TrimStart('/'); } private async Task SetSessionAsync(string token, DateTime? expiresAt) { _sessionToken = token; _expiresAt = expiresAt; _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _sessionToken); await _tokenStorage.SaveTokenAsync(_sessionToken, _expiresAt); } private void RestoreSession() { var (token, expiresAt) = _tokenStorage.GetTokenAsync().GetAwaiter().GetResult(); if (!string.IsNullOrEmpty(token) && (expiresAt == null || expiresAt > DateTime.UtcNow)) { _sessionToken = token; _expiresAt = expiresAt; _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _sessionToken); } } private async Task ClearSessionAsync() { _sessionToken = null; _expiresAt = null; _httpClient.DefaultRequestHeaders.Authorization = null; await _tokenStorage.ClearTokenAsync(); } private void EnsureAuthenticated() { if (!IsAuthenticated) { throw new InvalidOperationException("Not authenticated. Call LoginAsync first."); } } private async Task ReadErrorAsync(HttpResponseMessage response, CancellationToken cancellationToken) { try { var content = await response.Content.ReadAsStringAsync(cancellationToken); var error = JsonSerializer.Deserialize(content, _jsonOptions); return error?.Error ?? $"Request failed with status {response.StatusCode}"; } catch { return $"Request failed with status {response.StatusCode}"; } } public void Dispose() { if (!_disposed) { _httpClient.Dispose(); _disposed = true; } } } /// /// Configuration options for IronServicesClient. /// public class IronServicesClientOptions { /// /// Base URL for IronLicensing API (also used for auth/user operations). /// Default: https://ironlicensing.com /// public string LicensingUrl { get; set; } = "https://ironlicensing.com"; /// /// Base URL for IronNotify API. /// Default: https://ironnotify.com /// public string NotifyUrl { get; set; } = "https://ironnotify.com"; /// /// Base URL for IronTelemetry API. /// Default: https://irontelemetry.com /// public string TelemetryUrl { get; set; } = "https://irontelemetry.com"; }