using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; namespace ZB.MOM.WW.ScadaBridge.NotificationService; /// /// WP-11: OAuth2 Client Credentials token lifecycle — fetch, cache, refresh on expiry. /// Used for Microsoft 365 SMTP authentication. /// NS-006: tokens are cached per credential identity (tenant/client/secret), so a /// second SMTP configuration with different credentials never receives the first /// configuration's token. /// public class OAuth2TokenService { private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; // NS-006: cache keyed by a hash of the credential string. Each distinct // tenant/client/secret triple gets its own cached token and its own lock. private readonly ConcurrentDictionary _cache = new(); /// Initializes the service with an HTTP client factory and logger. /// Factory used to create HTTP clients for token endpoint requests. /// Logger instance. public OAuth2TokenService( IHttpClientFactory httpClientFactory, ILogger logger) { _httpClientFactory = httpClientFactory; _logger = logger; } /// /// Gets a valid access token, refreshing if expired. /// Credentials format: "tenantId:clientId:clientSecret" /// /// Colon-separated string in the form tenantId:clientId:clientSecret. /// Cancellation token. /// A valid OAuth2 access token string. public async Task GetTokenAsync(string credentials, CancellationToken cancellationToken = default) { var key = CredentialKey(credentials); var entry = _cache.GetOrAdd(key, _ => new CacheEntry()); if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry) { return entry.Token; } await entry.Lock.WaitAsync(cancellationToken); try { // Double-check after acquiring the per-credential lock. if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry) { return entry.Token; } var parts = credentials.Split(':', 3); if (parts.Length < 3) { throw new InvalidOperationException("OAuth2 credentials must be 'tenantId:clientId:clientSecret'"); } var tenantId = parts[0]; var clientId = parts[1]; var clientSecret = parts[2]; var client = _httpClientFactory.CreateClient("OAuth2"); var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; var form = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = clientId, ["client_secret"] = clientSecret, ["scope"] = "https://outlook.office365.com/.default" }); var response = await client.PostAsync(tokenUrl, form, cancellationToken); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var token = doc.RootElement.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("No access_token in OAuth2 response"); var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32(); entry.Token = token; entry.Expiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60); // Refresh 60s before expiry // NS-009: the token endpoint identity is logged by tenant only — never // the client secret or the access token itself. _logger.LogInformation( "OAuth2 token refreshed for tenant {Tenant}, expires in {ExpiresIn}s", tenantId, expiresIn); return token; } finally { entry.Lock.Release(); } } /// /// NS-006: a stable, non-reversible key for the credential string so the cache /// is partitioned by credential identity without holding the secret as a key. /// private static string CredentialKey(string credentials) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(credentials)); return Convert.ToHexString(hash); } private sealed class CacheEntry { public string? Token; public DateTimeOffset Expiry = DateTimeOffset.MinValue; public readonly SemaphoreSlim Lock = new(1, 1); } }