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);
}
}