using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
///
/// Shared service for acquiring Microsoft Graph access tokens via client credentials flow.
/// Used by AutopilotDeviceValidator and CorporateIdentifierValidator.
///
public class GraphTokenService
{
private static readonly TimeSpan ConsentStatusTtl = TimeSpan.FromMinutes(2);
// App-token cache tuning. TTL = expires_in - skew margin, clamped so a missing/pathological
// expires_in can neither thrash the cache nor pin a stale token. Max stays under the typical
// ~60-90 min AAD client-credentials lifetime; the 5-min margin covers clock skew between
// this worker and the Graph resource server plus the slowest downstream call in a request.
private static readonly TimeSpan TokenSkewMargin = TimeSpan.FromMinutes(5);
private static readonly TimeSpan MinTokenTtl = TimeSpan.FromSeconds(60);
private static readonly TimeSpan MaxTokenTtl = TimeSpan.FromMinutes(55);
private static readonly TimeSpan DefaultTokenLifetime = TimeSpan.FromMinutes(35); // when AAD omits expires_in
private static readonly TimeSpan TokenGateWait = TimeSpan.FromSeconds(10);
// One coalescing gate per tenant (see GetAccessTokenAsync). A SemaphoreSlim is tiny and
// tenant cardinality is bounded + validated upstream, so these are never evicted.
private readonly ConcurrentDictionary _tokenLocks = new();
// Per-tenant invalidation generation. Closes the write-after-invalidate race: a token POST
// that STARTED before InvalidateTenant captures the old generation and tags its cache entry
// with it; InvalidateTenant bumps the generation, so that late stale write is rejected by
// every reader (entry.Gen != current) even though it physically lands after the Remove.
// Lock-free — the comparison happens at read time against the latest generation.
private readonly ConcurrentDictionary _tokenCacheGen = new();
private long CurrentGen(string tenantId) => _tokenCacheGen.TryGetValue(tenantId, out var g) ? g : 0;
/// Cached app token tagged with the invalidation generation in effect when its acquire started.
private sealed record CachedAppToken(string Token, long Gen);
private readonly ILogger _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly IConfiguration _configuration;
public GraphTokenService(
ILogger logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
IConfiguration configuration)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_configuration = configuration;
}
public async Task GetConsentStatusAsync(string tenantId, CancellationToken ct = default)
{
var cacheKey = ConsentCacheKey(tenantId);
if (_cache.TryGetValue(cacheKey, out GraphConsentStatusResult? cached) && cached != null)
{
return cached;
}
var tokenResult = await GetAccessTokenAsync(tenantId, ct);
var result = new GraphConsentStatusResult
{
IsConsented = !string.IsNullOrWhiteSpace(tokenResult.AccessToken),
IsTransient = tokenResult.IsTransient,
Message = !string.IsNullOrWhiteSpace(tokenResult.AccessToken)
? "Admin consent is available for this tenant."
: tokenResult.IsTransient
? "Could not verify consent status due to a transient error. Will retry on next request."
: "Admin consent for Graph application permissions is missing or app credentials are invalid."
};
// Only cache definitive results — never cache transient failures
if (!tokenResult.IsTransient)
{
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ConsentStatusTtl
});
}
else
{
_logger.LogWarning(
"Consent status check for tenant {TenantId} returned transient error — result NOT cached",
tenantId);
}
return result;
}
public virtual async Task GetAccessTokenAsync(string tenantId, CancellationToken ct = default)
{
// Serve a still-valid cached app token for this tenant. The cache key is keyed strictly
// on the (already authenticated upstream) tenantId — the same value that drives the token
// URL below — so a token minted for one tenant can never be served to another.
if (TryGetCachedToken(tenantId, out var cachedToken))
{
return GraphTokenResult.Success(cachedToken);
}
var clientId = _configuration["EntraId:ClientId"];
var clientSecret = _configuration["EntraId:ClientSecret"];
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
_logger.LogError(
"Device validation is enabled but Entra ID app credentials are not configured. Set EntraId:ClientId and EntraId:ClientSecret.");
return GraphTokenResult.PermanentFailure();
}
// Stampede guard: coalesce concurrent cache-misses for the same tenant so an enrollment
// wave doesn't fan a burst of identical token POSTs at AAD (which would trip its own
// throttling). One acquirer fetches + populates the cache; the rest hit it after the
// gate. The wait is bounded so a pathological slow acquire (e.g. a no-consent tenant on
// the long retry chain) degrades to today's parallel-fetch behaviour instead of pinning
// callers indefinitely.
var gate = _tokenLocks.GetOrAdd(tenantId, _ => new SemaphoreSlim(1, 1));
bool gotGate;
try
{
gotGate = await gate.WaitAsync(TokenGateWait, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Caller budget exhausted while waiting — transient by definition (never cache).
return GraphTokenResult.TransientFailure();
}
try
{
// Double-check: another thread may have populated the cache while we waited. The
// gen-aware read also rejects a stale entry written by an acquire that overlapped an
// InvalidateTenant, so we fall through to a fresh mint instead of serving it.
if (gotGate && TryGetCachedToken(tenantId, out var tokenAfterWait))
{
return GraphTokenResult.Success(tokenAfterWait);
}
return await AcquireTokenViaClientCredentialsAsync(tenantId, clientId, clientSecret, ct);
}
finally
{
if (gotGate) gate.Release();
}
}
///
/// Performs the client-credentials POST (with the consent-propagation retry chain) and
/// caches a successful token until shortly before its expiry. Failures are never cached, so
/// a tenant that loses consent re-acquires (and fails) on the next request after expiry.
///
private async Task AcquireTokenViaClientCredentialsAsync(
string tenantId, string clientId, string clientSecret, CancellationToken ct)
{
// Capture the invalidation generation BEFORE the POST. If an InvalidateTenant lands
// while this POST is in flight, the generation changes and the token we cache below is
// tagged with the now-stale value — so readers reject it and re-mint fresh.
var genAtStart = CurrentGen(tenantId);
var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
// Retry with backoff to handle Azure AD consent propagation delays.
// After admin consent is granted, the service principal may not be immediately
// available in the tenant — Azure AD typically propagates within 30-90 seconds.
// Optional-feature callers should pass a CancellationToken with a tight budget
// so the long retry chain (5+15+30 s) cannot block the UI for a minute.
var retryDelays = new[] { TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30) };
string? responseBody = null;
int attempt = 0;
bool lastAttemptWasTransient = false;
while (true)
{
if (ct.IsCancellationRequested)
{
// Caller's budget exhausted — treat as transient so we never cache "no consent"
// for a tenant just because the optional path timed out.
return GraphTokenResult.TransientFailure();
}
try
{
var tokenClient = _httpClientFactory.CreateClient();
var body = new FormUrlEncodedContent(new Dictionary
{
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = "https://graph.microsoft.com/.default",
["grant_type"] = "client_credentials"
});
var response = await tokenClient.PostAsync(tokenUrl, body, ct);
responseBody = await response.Content.ReadAsStringAsync(ct);
if (response.IsSuccessStatusCode)
{
var tokenJson = JsonConvert.DeserializeObject(responseBody);
var accessToken = tokenJson?["access_token"]?.ToString();
if (!string.IsNullOrEmpty(accessToken))
{
// Cache the freshly minted token (never log its value). TTL derives from
// the token's own expires_in, minus a skew margin, clamped to a safe band.
CacheToken(tenantId, accessToken!, genAtStart, (long?)tokenJson?["expires_in"]);
}
return GraphTokenResult.Success(accessToken);
}
// Classify the error: consent-propagation errors are retryable but ultimately permanent (no consent).
// Server errors (500, 502, 503, 504) and timeouts (408) are truly transient.
var statusCode = (int)response.StatusCode;
var isConsentError = response.StatusCode == System.Net.HttpStatusCode.BadRequest
&& (responseBody.Contains("unauthorized_client", StringComparison.OrdinalIgnoreCase)
|| responseBody.Contains("AADSTS700016", StringComparison.OrdinalIgnoreCase)
|| responseBody.Contains("AADSTS7000215", StringComparison.OrdinalIgnoreCase));
var isServerError = statusCode == 408 || statusCode == 429
|| statusCode >= 500 && statusCode <= 599;
var isRetryable = response.StatusCode == System.Net.HttpStatusCode.Unauthorized
|| isConsentError || isServerError;
lastAttemptWasTransient = isServerError;
if (!isRetryable || attempt >= retryDelays.Length)
{
_logger.LogWarning(
"Failed to acquire Graph token for tenant {TenantId} after {Attempts} attempt(s). Status: {StatusCode}. Body: {ResponseBody}",
tenantId,
attempt + 1,
statusCode,
responseBody);
// Server errors are transient (infrastructure issue), consent errors are permanent (no consent granted)
return lastAttemptWasTransient
? GraphTokenResult.TransientFailure()
: GraphTokenResult.PermanentFailure();
}
var delay = retryDelays[attempt];
// Respect Retry-After header from Azure AD / Graph throttling, but cap
// at 120s to prevent a misbehaving server from blocking indefinitely
if (response.Headers.RetryAfter?.Delta is TimeSpan retryAfterDelta && retryAfterDelta > delay)
{
delay = retryAfterDelta > TimeSpan.FromSeconds(120)
? TimeSpan.FromSeconds(120)
: retryAfterDelta;
}
_logger.LogInformation(
"Graph token acquisition for tenant {TenantId} returned {StatusCode} (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s — likely Azure AD consent propagation delay.",
tenantId,
statusCode,
attempt + 1,
retryDelays.Length + 1,
delay.TotalSeconds);
await Task.Delay(delay, ct);
attempt++;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Caller budget exhausted between retries — transient by definition.
return GraphTokenResult.TransientFailure();
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
// Network errors and HttpClient-side timeouts are transient
if (attempt >= retryDelays.Length)
{
_logger.LogWarning(ex,
"Graph token acquisition for tenant {TenantId} failed with network error after {Attempts} attempt(s)",
tenantId, attempt + 1);
return GraphTokenResult.TransientFailure();
}
var delay = retryDelays[attempt];
_logger.LogWarning(ex,
"Graph token acquisition for tenant {TenantId} network error (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s",
tenantId, attempt + 1, retryDelays.Length + 1, delay.TotalSeconds);
try { await Task.Delay(delay, ct); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return GraphTokenResult.TransientFailure();
}
attempt++;
}
}
}
private static string TokenCacheKey(string tenantId) => $"graph-token:{tenantId}";
private static string ConsentCacheKey(string tenantId) => $"graph-consent-status:{tenantId}";
///
/// Drops this tenant's cached app token (and consent-status verdict) so the next acquire
/// mints a fresh JWT from AAD. Required for the consent / Graph-permission fresh-read
/// contract: after an admin grants consent or assigns app roles, the OLD token still carries
/// the OLD roles claim until it expires — serving it would make a post-grant "Refresh"
/// keep reporting "not granted" for up to the cache TTL. The single fresh-read entry point
/// GraphFeatureDetector.InvalidateTenant calls this so BOTH cache layers are cleared
/// together (the detector's parsed-roles cache AND this raw-token cache).
///
public virtual void InvalidateTenant(string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId)) return;
// Bump the generation FIRST so any token POST already in flight (which captured the old
// generation) writes an entry that readers reject as stale — this closes the
// write-after-invalidate race, not just the already-written entry. Then drop the current
// entries to free memory and clear the coarse consent-status verdict.
_tokenCacheGen.AddOrUpdate(tenantId, 1, (_, v) => v + 1);
_cache.Remove(TokenCacheKey(tenantId));
_cache.Remove(ConsentCacheKey(tenantId));
}
private bool TryGetCachedToken(string tenantId, out string token)
{
// Accept the cached token only if its generation still matches the tenant's current one.
// A mismatch means an InvalidateTenant ran since this entry's acquire started, so the
// token may carry a stale roles claim — treat it as a miss and re-mint.
if (_cache.TryGetValue(TokenCacheKey(tenantId), out CachedAppToken? cached)
&& cached != null
&& !string.IsNullOrEmpty(cached.Token)
&& cached.Gen == CurrentGen(tenantId))
{
token = cached.Token;
return true;
}
token = string.Empty;
return false;
}
private void CacheToken(string tenantId, string accessToken, long genAtStart, long? expiresInSeconds)
{
// expires_in is reported by AAD over TLS, so it is trustworthy — but still clamped so a
// missing or pathological value cannot pin a stale token (cap) or cause thrash (floor).
var lifetime = expiresInSeconds is > 0
? TimeSpan.FromSeconds(expiresInSeconds.Value)
: DefaultTokenLifetime;
var ttl = lifetime - TokenSkewMargin;
if (ttl < MinTokenTtl) ttl = MinTokenTtl;
if (ttl > MaxTokenTtl) ttl = MaxTokenTtl;
_cache.Set(TokenCacheKey(tenantId), new CachedAppToken(accessToken, genAtStart), new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl,
});
}
}
///
/// Result of a Graph token acquisition attempt.
/// Distinguishes between success, permanent failure (no consent), and transient failure (network/server error).
///
public class GraphTokenResult
{
public string? AccessToken { get; private set; }
///
/// True when the failure is transient (network error, server error, timeout).
/// Transient failures should NOT be cached as "no consent".
///
public bool IsTransient { get; private set; }
public static GraphTokenResult Success(string? token) => new() { AccessToken = token };
public static GraphTokenResult PermanentFailure() => new() { AccessToken = null, IsTransient = false };
public static GraphTokenResult TransientFailure() => new() { AccessToken = null, IsTransient = true };
}
public class GraphConsentStatusResult
{
public bool IsConsented { get; set; }
///
/// True when the result is due to a transient error (network/server issue).
/// Callers should treat this as "unknown" rather than "not consented".
///
public bool IsTransient { get; set; }
public string? Message { get; set; }
}
}