using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; namespace ZymonicServices; public class ZymonicAA : IZymonicAA { private AuthResponse? authResponse; private string _clientId; private string _clientSecret; private string _scope; private string? _baseUri; private string? _systemName; private IZymonicSettings _settings; private string? _accessToken; private string? _refreshToken; private string? _currentUser; private DateTime _expiryTime = DateTime.UtcNow; private IZymonicLogger _logger; private static HttpClient? singleHttpClient; private Dictionary singleHttpClientWithAuth = new Dictionary(); JsonSerializerOptions jsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString }; public ZymonicAA(IZymonicSettings settings, IZymonicLogger logger) { _settings = settings; _logger = logger; _clientId = _settings.Setting("ClientId") ?? throw new ArgumentNullException("Mandatory parameter", nameof(_clientId)); ; _clientSecret = _settings.Setting("ClientSecret") ?? throw new ArgumentNullException("Mandatory parameter", nameof(_clientSecret)); ; _scope = _settings.Setting("Scope") ?? throw new ArgumentNullException("Mandatory parameter", nameof(_scope)); ; // check for an existing access token in settings _accessToken = _settings.Setting("AccessToken"); _refreshToken = _settings.Setting("RefreshToken"); _currentUser = _settings.Setting("CurrentUser"); try { string? rawExpiryTime = _settings.Setting("ExpiryTime"); if (rawExpiryTime != null) { _expiryTime = DateTime.Parse(rawExpiryTime); } } catch (FormatException e) { _logger.LogError(e.ToString()); } } public void GetSystemSettings() { _baseUri = _settings.Setting("BaseUri") ?? throw new ArgumentNullException("Mandatory parameter", nameof(_baseUri)); _systemName = _settings.Setting("SystemName") ?? throw new ArgumentNullException("Mandatory parameter", nameof(_systemName)); } private HttpClient GetHttpClient(bool forceNew = false) { if (forceNew) { singleHttpClient = null; } if (singleHttpClient is null) { GetSystemSettings(); singleHttpClient = new HttpClient(new HttpLoggingHandler(_logger, new HttpClientHandler() { UseCookies = false })) { // IMPORTANT - the trailing / is required here and must not be be duplicated in the GET/POST BaseAddress = new Uri($"{_baseUri}/{_systemName}/"), Timeout = TimeSpan.FromSeconds(30), }; } return singleHttpClient; } public async Task Login(string username, string password) { bool success = false; if (username is not null && password is not null) { List> formData = new List> { new KeyValuePair("username", username), new KeyValuePair("password", password), new KeyValuePair("client_id", _clientId), new KeyValuePair("client_secret", _clientSecret), new KeyValuePair("scope",_scope), new KeyValuePair("grant_type", "password") }; HttpResponseMessage response = await GetHttpClient(true).PostAsync("oauth/token", new FormUrlEncodedContent(formData)).ConfigureAwait(true); response.EnsureSuccessStatusCode(); authResponse = await response.Content.ReadFromJsonAsync(jsonOptions).ConfigureAwait(true); if (authResponse is null) { throw new TokenException(); } success = ProcessAuthResponse(authResponse); _currentUser = username; } return success; } private static readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); public async Task GetToken(int margin = 30) { _logger.LogInformation("Token expires at {ExpiryTime} - currently {CurrentTime}", _expiryTime, DateTime.UtcNow); if (_expiryTime < DateTime.UtcNow.AddSeconds(margin)) { await _tokenSemaphore.WaitAsync(); try { // Double-check after acquiring the lock if (_expiryTime < DateTime.UtcNow.AddSeconds(margin)) { _logger.LogInformation("Token expires at {ExpiryTime} - Attempting to refresh it.", _expiryTime); if (_refreshToken is not null) { var formData = new List> { new KeyValuePair("client_id", _clientId), new KeyValuePair("client_secret", _clientSecret), new KeyValuePair("grant_type", "refresh_token"), new KeyValuePair("refresh_token", _refreshToken) }; HttpResponseMessage response = await GetHttpClient().PostAsync("oauth/token", new FormUrlEncodedContent(formData)).ConfigureAwait(true); response.EnsureSuccessStatusCode(); authResponse = await response.Content.ReadFromJsonAsync(jsonOptions).ConfigureAwait(true); if (authResponse is null) { throw new TokenException(); } ProcessAuthResponse(authResponse); } } } finally { _tokenSemaphore.Release(); } } return _accessToken ?? throw new TokenException(); } private bool ProcessAuthResponse(AuthResponse authResponse) { bool success = false; int expiresIn = 0; if (authResponse != null && authResponse.AccessToken != null) { _accessToken = authResponse.AccessToken; _refreshToken = authResponse.RefreshToken; _currentUser = authResponse.Username; try { if (authResponse.ExpiresIn is not null) { expiresIn = Int32.Parse(authResponse.ExpiresIn); } else { _logger.LogError("Expiry not found in the authentication response (token will expire instantly)"); } } catch (FormatException e) { _logger.LogError(e.ToString()); } _expiryTime = DateTime.UtcNow.AddSeconds(expiresIn); _logger.LogInformation("Received an access token that will expire at {ExpiryTime}", _expiryTime); _settings.Setting("AccessToken", _accessToken); _settings.Setting("RefreshToken", _refreshToken); _settings.Setting("CurrentUser", _currentUser); _settings.Setting("ExpiryTime", _expiryTime.ToString()); success = true; } else { throw new TokenException(); } return success; } public string? GetCurrentUser() { return _currentUser; } public bool LoggedIn() { return _accessToken != null; } public void Logout() { _settings.ClearSetting("AccessToken"); _settings.ClearSetting("RefreshToken"); _settings.ClearSetting("CurrentUser"); _accessToken = null; } public string LoginError() { return "Generic Login Failure"; } public async Task PingAsync(CancellationToken ct, bool requireAuthenticated = true, int TimeoutSeconds = 30) { PingRequest request = new PingRequest(); request.ZymonicHeader = new ZymonicAPIHeader(_settings.SystemName(), "login", false); var apiDecodedResponse = await ApiWrapper(request, ct, TimeoutSeconds).ConfigureAwait(true); if (apiDecodedResponse is null) { throw new ApiResponseMissingOrEmpty(); } if (requireAuthenticated && apiDecodedResponse.Authenticated != "Y") { throw new UnauthenticatedResponseException(); } return apiDecodedResponse; } private async Task ApiWrapper(PingRequest request, CancellationToken ct, int TimeoutSeconds = 30) { JsonSerializerOptions jsonOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString }; HttpResponseMessage response = await SingleHttpClientWithAuth(TimeoutSeconds).PostAsJsonAsync("", request, jsonOptions, ct).ConfigureAwait(true); response.EnsureSuccessStatusCode(); if (response.Content is null) { throw new ApiResponseMissingOrEmpty(); } return await response.Content.ReadFromJsonAsync(jsonOptions).ConfigureAwait(true); } private HttpClient SingleHttpClientWithAuth(int TimeoutSeconds = 120) { if (!singleHttpClientWithAuth.ContainsKey(TimeoutSeconds) || singleHttpClientWithAuth[TimeoutSeconds] is null) { singleHttpClientWithAuth[TimeoutSeconds] = new HttpClient(new ZymonicAPIHandler(_logger, this, new HttpLoggingHandler(_logger))) { BaseAddress = new Uri(_settings.BaseUri()), Timeout = TimeSpan.FromSeconds(TimeoutSeconds) }; } return singleHttpClientWithAuth[TimeoutSeconds]; } } public class UnauthenticatedResponseException : Exception { } public class PingRequest { public ZymonicAPIHeader? ZymonicHeader { get; set; } } public class PingResponse { public string? Authenticated { get; set; } public List? session_errors { get; set; } } public class AuthResponse { [JsonPropertyName("access_token")] public string? AccessToken { get; set; } [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } [JsonPropertyName("expires_in")] public string? ExpiresIn { get; set; } [JsonPropertyName("username")] public string? Username { get; set; } } public class TokenException : Exception { }