7b0b9c7365
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
125 lines
4.9 KiB
C#
125 lines
4.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class OAuth2TokenService
|
|
{
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly ILogger<OAuth2TokenService> _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<string, CacheEntry> _cache = new();
|
|
|
|
/// <summary>Initializes the service with an HTTP client factory and logger.</summary>
|
|
/// <param name="httpClientFactory">Factory used to create HTTP clients for token endpoint requests.</param>
|
|
/// <param name="logger">Logger instance.</param>
|
|
public OAuth2TokenService(
|
|
IHttpClientFactory httpClientFactory,
|
|
ILogger<OAuth2TokenService> logger)
|
|
{
|
|
_httpClientFactory = httpClientFactory;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a valid access token, refreshing if expired.
|
|
/// Credentials format: "tenantId:clientId:clientSecret"
|
|
/// </summary>
|
|
/// <param name="credentials">Colon-separated string in the form <c>tenantId:clientId:clientSecret</c>.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A valid OAuth2 access token string.</returns>
|
|
public async Task<string> 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<string, string>
|
|
{
|
|
["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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|