add Corporate Identifier Validation with preview-gated UI

Adds a second device validation method alongside existing Autopilot Device
Validation. Corporate Identifier Validation checks devices against Intune
Corporate Device Identifiers (manufacturer + model + serial number) via the
Graph beta searchExistingIdentities endpoint.

Backend: Extract GraphTokenService from AutopilotDeviceValidator for shared
token/consent logic. Add CorporateIdentifierValidator with caching. Update
SecurityValidator with cascading validation (Autopilot first, Corporate
Identifier fallback). Wire new validator through all agent endpoints.

Frontend: Add second toggle in settings gated behind preview mode. Shared
consent flow skips redirect when consent already exists from other toggle.
Section renamed to "Enrollment Device Validation" in preview mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-03-03 10:19:06 +01:00
parent 1d2874514e
commit 20e93aa892
16 changed files with 544 additions and 157 deletions
@@ -15,20 +15,20 @@ public class AutopilotDeviceValidationConsentFunction
private readonly IConfiguration _configuration;
private readonly TenantAdminsService _tenantAdminsService;
private readonly GalacticAdminService _galacticAdminService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly GraphTokenService _graphTokenService;
public AutopilotDeviceValidationConsentFunction(
ILogger<AutopilotDeviceValidationConsentFunction> logger,
IConfiguration configuration,
TenantAdminsService tenantAdminsService,
GalacticAdminService galacticAdminService,
AutopilotDeviceValidator autopilotDeviceValidator)
GraphTokenService graphTokenService)
{
_logger = logger;
_configuration = configuration;
_tenantAdminsService = tenantAdminsService;
_galacticAdminService = galacticAdminService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_graphTokenService = graphTokenService;
}
[Function("GetAutopilotDeviceValidationConsentUrl")]
@@ -85,7 +85,7 @@ public class AutopilotDeviceValidationConsentFunction
return authError;
}
var result = await _autopilotDeviceValidator.GetConsentStatusAsync(tenantId);
var result = await _graphTokenService.GetConsentStatusAsync(tenantId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
@@ -23,6 +23,7 @@ namespace AutopilotMonitor.Functions.Functions
private readonly ImeLogPatternService _imeLogPatternService;
private readonly RateLimitService _rateLimitService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
public GetAgentConfigFunction(
ILogger<GetAgentConfigFunction> logger,
@@ -31,7 +32,8 @@ namespace AutopilotMonitor.Functions.Functions
GatherRuleService gatherRuleService,
ImeLogPatternService imeLogPatternService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator)
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator)
{
_logger = logger;
_configService = configService;
@@ -40,6 +42,7 @@ namespace AutopilotMonitor.Functions.Functions
_imeLogPatternService = imeLogPatternService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
}
[Function("GetAgentConfig")]
@@ -68,6 +71,7 @@ namespace AutopilotMonitor.Functions.Functions
_configService,
_rateLimitService,
_autopilotDeviceValidator,
_corporateIdentifierValidator,
_logger
);
@@ -21,17 +21,20 @@ namespace AutopilotMonitor.Functions.Functions
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
public GetDiagnosticsUploadUrlFunction(
ILogger<GetDiagnosticsUploadUrlFunction> logger,
TenantConfigurationService configService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator)
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator)
{
_logger = logger;
_configService = configService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
}
[Function("GetDiagnosticsUploadUrl")]
@@ -76,6 +79,7 @@ namespace AutopilotMonitor.Functions.Functions
_configService,
_rateLimitService,
_autopilotDeviceValidator,
_corporateIdentifierValidator,
_logger
);
@@ -21,6 +21,7 @@ namespace AutopilotMonitor.Functions.Functions
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly AnalyzeRuleService _analyzeRuleService;
private readonly TeamsNotificationService _teamsNotificationService;
private readonly BlockedDeviceService _blockedDeviceService;
@@ -31,6 +32,7 @@ namespace AutopilotMonitor.Functions.Functions
TenantConfigurationService configService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
AnalyzeRuleService analyzeRuleService,
TeamsNotificationService teamsNotificationService,
BlockedDeviceService blockedDeviceService)
@@ -40,6 +42,7 @@ namespace AutopilotMonitor.Functions.Functions
_configService = configService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_analyzeRuleService = analyzeRuleService;
_teamsNotificationService = teamsNotificationService;
_blockedDeviceService = blockedDeviceService;
@@ -71,6 +74,7 @@ namespace AutopilotMonitor.Functions.Functions
_configService,
_rateLimitService,
_autopilotDeviceValidator,
_corporateIdentifierValidator,
_logger
);
@@ -17,19 +17,22 @@ namespace AutopilotMonitor.Functions.Functions
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
public RegisterSessionFunction(
ILogger<RegisterSessionFunction> logger,
TableStorageService storageService,
TenantConfigurationService configService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator)
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator)
{
_logger = logger;
_storageService = storageService;
_configService = configService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
}
[Function("RegisterSession")]
@@ -65,6 +68,7 @@ namespace AutopilotMonitor.Functions.Functions
_configService,
_rateLimitService,
_autopilotDeviceValidator,
_corporateIdentifierValidator,
_logger,
registration.SessionId
);
@@ -28,6 +28,7 @@ namespace AutopilotMonitor.Functions.Functions
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly TelemetryClient _telemetryClient;
public ReportAgentErrorFunction(
@@ -35,12 +36,14 @@ namespace AutopilotMonitor.Functions.Functions
TenantConfigurationService configService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
TelemetryClient telemetryClient)
{
_logger = logger;
_configService = configService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_telemetryClient = telemetryClient;
}
@@ -69,6 +72,7 @@ namespace AutopilotMonitor.Functions.Functions
_configService,
_rateLimitService,
_autopilotDeviceValidator,
_corporateIdentifierValidator,
_logger
);
@@ -110,7 +110,9 @@ builder.Services.AddHttpClient<GitHubRuleRepository>();
builder.Services.AddSingleton<MaintenanceService>();
builder.Services.AddSingleton<BlockedDeviceService>();
builder.Services.AddSingleton<SessionReportService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
builder.Services.AddHttpClient<TeamsNotificationService>();
builder.Services.AddHttpClient<TelegramNotificationService>();
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Configuration;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -16,23 +17,22 @@ namespace AutopilotMonitor.Functions.Security
{
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
private static readonly TimeSpan ConsentStatusTtl = TimeSpan.FromMinutes(2);
private readonly ILogger<AutopilotDeviceValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly IConfiguration _configuration;
private readonly GraphTokenService _graphTokenService;
public AutopilotDeviceValidator(
ILogger<AutopilotDeviceValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
IConfiguration configuration)
GraphTokenService graphTokenService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_configuration = configuration;
_graphTokenService = graphTokenService;
}
public async Task<AutopilotDeviceValidationResult> ValidateAutopilotDeviceAsync(
@@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Security
try
{
var accessToken = await GetGraphAccessTokenAsync(tenantId);
var accessToken = await _graphTokenService.GetAccessTokenAsync(tenantId);
if (string.IsNullOrEmpty(accessToken))
{
// Do not cache token-acquisition failures — this is a backend/propagation issue,
@@ -162,105 +162,6 @@ namespace AutopilotMonitor.Functions.Security
}
}
public async Task<GraphConsentStatusResult> GetConsentStatusAsync(string tenantId)
{
var cacheKey = $"autopilot-device-validation-consent:{tenantId}";
if (_cache.TryGetValue(cacheKey, out GraphConsentStatusResult? cached) && cached != null)
{
return cached;
}
var accessToken = await GetGraphAccessTokenAsync(tenantId);
var result = new GraphConsentStatusResult
{
IsConsented = !string.IsNullOrWhiteSpace(accessToken),
Message = string.IsNullOrWhiteSpace(accessToken)
? "Admin consent for Graph application permissions is missing or app credentials are invalid."
: "Admin consent is available for this tenant."
};
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ConsentStatusTtl
});
return result;
}
private async Task<string?> GetGraphAccessTokenAsync(string tenantId)
{
var clientId = _configuration["EntraId:ClientId"];
var clientSecret = _configuration["EntraId:ClientSecret"];
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
{
_logger.LogError(
"Autopilot device validation is enabled but Entra ID app credentials are not configured. Set EntraId:ClientId and EntraId:ClientSecret.");
return null;
}
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.
var retryDelays = new[] { TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30) };
string? responseBody = null;
int attempt = 0;
while (true)
{
var tokenClient = _httpClientFactory.CreateClient();
var body = new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = "https://graph.microsoft.com/.default",
["grant_type"] = "client_credentials"
});
var response = await tokenClient.PostAsync(tokenUrl, body);
responseBody = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
return tokenJson?["access_token"]?.ToString();
}
// Only retry on transient consent-propagation errors (unauthorized_client, invalid_client).
// Do not retry on permanent errors like invalid credentials or tenant not found.
var isRetryable = response.StatusCode == System.Net.HttpStatusCode.Unauthorized
|| response.StatusCode == System.Net.HttpStatusCode.BadRequest
&& (responseBody.Contains("unauthorized_client", StringComparison.OrdinalIgnoreCase)
|| responseBody.Contains("AADSTS700016", StringComparison.OrdinalIgnoreCase)
|| responseBody.Contains("AADSTS7000215", StringComparison.OrdinalIgnoreCase));
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,
(int)response.StatusCode,
responseBody);
return null;
}
var delay = retryDelays[attempt];
_logger.LogInformation(
"Graph token acquisition for tenant {TenantId} returned {StatusCode} (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s — likely Azure AD consent propagation delay.",
tenantId,
(int)response.StatusCode,
attempt + 1,
retryDelays.Length + 1,
delay.TotalSeconds);
await Task.Delay(delay);
attempt++;
}
}
private static string BuildCacheKey(string tenantId, string serialNumber, string? sessionId)
{
if (!string.IsNullOrWhiteSpace(sessionId))
@@ -293,10 +194,4 @@ namespace AutopilotMonitor.Functions.Security
public string? AutopilotDeviceId { get; set; }
public string? ErrorMessage { get; set; }
}
public class GraphConsentStatusResult
{
public bool IsConsented { get; set; }
public string? Message { get; set; }
}
}
@@ -0,0 +1,199 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Validates devices against Intune Corporate Device Identifiers via Microsoft Graph beta API.
/// Uses the importedDeviceIdentities/searchExistingIdentities endpoint with manufacturerModelSerial type.
/// Caches positive/negative lookups to reduce Graph traffic.
/// </summary>
public class CorporateIdentifierValidator
{
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
private readonly ILogger<CorporateIdentifierValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly GraphTokenService _graphTokenService;
public CorporateIdentifierValidator(
ILogger<CorporateIdentifierValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
GraphTokenService graphTokenService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_graphTokenService = graphTokenService;
}
public async Task<CorporateIdentifierValidationResult> ValidateAsync(
string tenantId,
string? manufacturer,
string? model,
string? serialNumber,
string? sessionId = null)
{
if (string.IsNullOrWhiteSpace(manufacturer) || string.IsNullOrWhiteSpace(model) || string.IsNullOrWhiteSpace(serialNumber))
{
return new CorporateIdentifierValidationResult
{
IsValid = false,
ErrorMessage = "Manufacturer, model, or serial number header not provided"
};
}
var normalizedManufacturer = manufacturer.Trim();
var normalizedModel = model.Trim();
var normalizedSerial = serialNumber.Trim();
var cacheKey = BuildCacheKey(tenantId, normalizedManufacturer, normalizedModel, normalizedSerial, sessionId);
if (_cache.TryGetValue(cacheKey, out CorporateIdentifierValidationResult? cached) && cached != null)
{
return cached;
}
try
{
var accessToken = await _graphTokenService.GetAccessTokenAsync(tenantId);
if (string.IsNullOrEmpty(accessToken))
{
// Do not cache token-acquisition failures — this is a backend/propagation issue,
// not a device issue. The next enrollment attempt should retry immediately.
return new CorporateIdentifierValidationResult
{
IsValid = false,
ErrorMessage = "Graph access token could not be acquired"
};
}
var graphClient = _httpClientFactory.CreateClient();
graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
// POST https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities
// Body: {"importedDeviceIdentities":[{"importedDeviceIdentityType":"manufacturerModelSerial",
// "importedDeviceIdentifier":"manufacturer,model,serialNumber"}]}
var identifier = $"{normalizedManufacturer},{normalizedModel},{normalizedSerial}";
var requestBody = new
{
importedDeviceIdentities = new[]
{
new
{
importedDeviceIdentityType = "manufacturerModelSerial",
importedDeviceIdentifier = identifier
}
}
};
var content = new StringContent(
JsonConvert.SerializeObject(requestBody),
Encoding.UTF8,
"application/json");
var graphUrl = "https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities";
var response = await graphClient.PostAsync(graphUrl, content);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Corporate identifier validation Graph query failed for tenant {TenantId}. Status: {StatusCode}. Body: {ResponseBody}",
tenantId,
(int)response.StatusCode,
responseBody);
return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult
{
IsValid = false,
ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}"
}, isPositive: false);
}
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
var identities = data?["value"] as JArray;
if (identities == null || identities.Count == 0)
{
return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult
{
IsValid = false,
ErrorMessage = $"Device '{identifier}' is not registered as a Corporate Identifier"
}, isPositive: false);
}
var result = new CorporateIdentifierValidationResult
{
IsValid = true,
Identifier = identifier
};
_logger.LogInformation(
"Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier}",
tenantId,
sessionId ?? "<none>",
identifier);
return CacheAndReturn(cacheKey, result, isPositive: true);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error during corporate identifier validation for tenant {TenantId}, session {SessionId}, identifier {Manufacturer},{Model},{SerialNumber}",
tenantId,
sessionId ?? "<none>",
normalizedManufacturer,
normalizedModel,
normalizedSerial);
return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult
{
IsValid = false,
ErrorMessage = $"Error during corporate identifier validation: {ex.Message}"
}, isPositive: false);
}
}
private static string BuildCacheKey(string tenantId, string manufacturer, string model, string serialNumber, string? sessionId)
{
var baseKey = $"corporate-id-validation:{tenantId}:{manufacturer}:{model}:{serialNumber}";
if (!string.IsNullOrWhiteSpace(sessionId))
{
return $"{baseKey}:{sessionId}";
}
return baseKey;
}
private CorporateIdentifierValidationResult CacheAndReturn(
string cacheKey,
CorporateIdentifierValidationResult result,
bool isPositive)
{
var ttl = isPositive ? PositiveCacheTtl : NegativeCacheTtl;
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
});
return result;
}
}
public class CorporateIdentifierValidationResult
{
public bool IsValid { get; set; }
public string? Identifier { get; set; }
public string? ErrorMessage { get; set; }
}
}
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
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
{
/// <summary>
/// Shared service for acquiring Microsoft Graph access tokens via client credentials flow.
/// Used by AutopilotDeviceValidator and CorporateIdentifierValidator.
/// </summary>
public class GraphTokenService
{
private static readonly TimeSpan ConsentStatusTtl = TimeSpan.FromMinutes(2);
private readonly ILogger<GraphTokenService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly IConfiguration _configuration;
public GraphTokenService(
ILogger<GraphTokenService> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
IConfiguration configuration)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_configuration = configuration;
}
public async Task<GraphConsentStatusResult> GetConsentStatusAsync(string tenantId)
{
var cacheKey = $"graph-consent-status:{tenantId}";
if (_cache.TryGetValue(cacheKey, out GraphConsentStatusResult? cached) && cached != null)
{
return cached;
}
var accessToken = await GetAccessTokenAsync(tenantId);
var result = new GraphConsentStatusResult
{
IsConsented = !string.IsNullOrWhiteSpace(accessToken),
Message = string.IsNullOrWhiteSpace(accessToken)
? "Admin consent for Graph application permissions is missing or app credentials are invalid."
: "Admin consent is available for this tenant."
};
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ConsentStatusTtl
});
return result;
}
public async Task<string?> GetAccessTokenAsync(string tenantId)
{
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 null;
}
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.
var retryDelays = new[] { TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30) };
string? responseBody = null;
int attempt = 0;
while (true)
{
var tokenClient = _httpClientFactory.CreateClient();
var body = new FormUrlEncodedContent(new Dictionary<string, string>
{
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = "https://graph.microsoft.com/.default",
["grant_type"] = "client_credentials"
});
var response = await tokenClient.PostAsync(tokenUrl, body);
responseBody = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
return tokenJson?["access_token"]?.ToString();
}
// Only retry on transient consent-propagation errors (unauthorized_client, invalid_client).
// Do not retry on permanent errors like invalid credentials or tenant not found.
var isRetryable = response.StatusCode == System.Net.HttpStatusCode.Unauthorized
|| response.StatusCode == System.Net.HttpStatusCode.BadRequest
&& (responseBody.Contains("unauthorized_client", StringComparison.OrdinalIgnoreCase)
|| responseBody.Contains("AADSTS700016", StringComparison.OrdinalIgnoreCase)
|| responseBody.Contains("AADSTS7000215", StringComparison.OrdinalIgnoreCase));
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,
(int)response.StatusCode,
responseBody);
return null;
}
var delay = retryDelays[attempt];
_logger.LogInformation(
"Graph token acquisition for tenant {TenantId} returned {StatusCode} (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s — likely Azure AD consent propagation delay.",
tenantId,
(int)response.StatusCode,
attempt + 1,
retryDelays.Length + 1,
delay.TotalSeconds);
await Task.Delay(delay);
attempt++;
}
}
}
public class GraphConsentStatusResult
{
public bool IsConsented { get; set; }
public string? Message { get; set; }
}
}
@@ -28,10 +28,11 @@ namespace AutopilotMonitor.Functions.Security
TenantConfigurationService configService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
ILogger logger,
string? sessionId = null)
{
var validator = new SecurityValidator(configService, rateLimitService, autopilotDeviceValidator, logger);
var validator = new SecurityValidator(configService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger);
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
if (!validation.IsValid)
@@ -44,17 +44,20 @@ namespace AutopilotMonitor.Functions.Security
private readonly TenantConfigurationService _configService;
private readonly RateLimitService _rateLimitService;
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly ILogger _logger;
public SecurityValidator(
TenantConfigurationService configService,
RateLimitService rateLimitService,
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
ILogger logger)
{
_configService = configService;
_rateLimitService = rateLimitService;
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_logger = logger;
}
@@ -95,16 +98,16 @@ namespace AutopilotMonitor.Functions.Security
}
// Security validation is always enforced (no longer configurable per tenant)
// Hard gate: tenant must enable Autopilot device validation before agent traffic is accepted.
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
// Galactic Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
if (!config.ValidateAutopilotDevice && !config.AllowInsecureAgentRequests)
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.AllowInsecureAgentRequests)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Autopilot device validation is required",
Details = "Enable 'Autopilot Device Validation' in Configuration before using the agent ingestion endpoints."
ErrorMessage = "Device validation is required",
Details = "Enable 'Autopilot Device Validation' or 'Corporate Identifier Validation' in Configuration before using the agent ingestion endpoints."
};
}
@@ -179,29 +182,50 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 4. Validate device against Intune Autopilot registration (admin consent needs to be given during setup)
string? serialNumber = null;
// 4. Validate device registration (Autopilot and/or Corporate Identifier)
string? serialNumber = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
string? autopilotDeviceId = null;
bool deviceValidated = false;
string? deviceValidationError = null;
if (config.ValidateAutopilotDevice)
{
serialNumber = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
var serialValidation = await _autopilotDeviceValidator.ValidateAutopilotDeviceAsync(tenantId, serialNumber, sessionId);
if (!serialValidation.IsValid)
var autopilotResult = await _autopilotDeviceValidator.ValidateAutopilotDeviceAsync(tenantId, serialNumber, sessionId);
if (autopilotResult.IsValid)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Device not registered in Autopilot",
Details = serialValidation.ErrorMessage
};
deviceValidated = true;
autopilotDeviceId = autopilotResult.AutopilotDeviceId;
}
else
{
deviceValidationError = autopilotResult.ErrorMessage;
}
}
autopilotDeviceId = serialValidation.AutopilotDeviceId;
if (!deviceValidated && config.ValidateCorporateIdentifier)
{
var corpResult = await _corporateIdentifierValidator.ValidateAsync(tenantId, manufacturer, model, serialNumber, sessionId);
if (corpResult.IsValid)
{
deviceValidated = true;
}
else
{
deviceValidationError = corpResult.ErrorMessage;
}
}
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier) && !deviceValidated)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Device not registered",
Details = deviceValidationError
};
}
// All checks passed
@@ -203,6 +203,7 @@ namespace AutopilotMonitor.Functions.Services
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
{ "ModelWhitelist", config.ModelWhitelist },
{ "ValidateAutopilotDevice", config.ValidateAutopilotDevice },
{ "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier },
{ "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests },
{ "DataRetentionDays", config.DataRetentionDays },
{ "SessionTimeoutHours", config.SessionTimeoutHours },
@@ -248,6 +249,7 @@ namespace AutopilotMonitor.Functions.Services
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*",
ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false,
ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false,
AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false,
DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90,
SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5,
@@ -90,6 +90,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool ValidateAutopilotDevice { get; set; } = false;
/// <summary>
/// Whether to validate devices against Intune Corporate Device Identifiers
/// (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities).
/// Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All)
/// </summary>
public bool ValidateCorporateIdentifier { get; set; } = false;
/// <summary>
/// Emergency bypass for agent security gate (Galactic Admin use only).
/// If true, agent requests are accepted even when ValidateAutopilotDevice is false.
@@ -3,32 +3,47 @@
interface AutopilotValidationSectionProps {
validateAutopilotDevice: boolean;
setValidateAutopilotDevice: (value: boolean) => void;
validateCorporateIdentifier: boolean;
setValidateCorporateIdentifier: (value: boolean) => void;
autopilotConsentInProgress: boolean;
saving: boolean;
onBeginConsent: () => void;
onBeginConsent: (trigger: 'autopilot' | 'corporate') => void;
previewMode: boolean;
}
export default function AutopilotValidationSection({
validateAutopilotDevice,
setValidateAutopilotDevice,
validateCorporateIdentifier,
setValidateCorporateIdentifier,
autopilotConsentInProgress,
saving,
onBeginConsent,
previewMode,
}: AutopilotValidationSectionProps) {
const anyValidationEnabled = validateAutopilotDevice || validateCorporateIdentifier;
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-gray-900">Autopilot Device Validation</h2>
<p className="text-sm text-gray-500 mt-1">Validate devices against Intune Windows Autopilot registration (mandatory for agent ingestion)</p>
<h2 className="text-xl font-semibold text-gray-900">
{previewMode ? "Enrollment Device Validation" : "Autopilot Device Validation"}
</h2>
<p className="text-sm text-gray-500 mt-1">
{previewMode
? "Validate devices against Intune registrations before accepting agent data (mandatory for agent ingestion)"
: "Validate devices against Intune Windows Autopilot registration (mandatory for agent ingestion)"}
</p>
</div>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${validateAutopilotDevice ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{validateAutopilotDevice ? "Enabled" : "Disabled"}
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${anyValidationEnabled ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{anyValidationEnabled ? "Enabled" : "Disabled"}
</span>
</div>
</div>
<div className="p-6 space-y-4">
{/* Autopilot Device Validation toggle */}
<label className="flex items-start justify-between gap-4">
<div>
<p className="font-medium text-gray-900">Enable Autopilot Device Validation</p>
@@ -41,7 +56,12 @@ export default function AutopilotValidationSection({
if (validateAutopilotDevice) {
setValidateAutopilotDevice(false);
} else {
onBeginConsent();
// If corporate identifier is already on, consent exists — just enable
if (validateCorporateIdentifier) {
setValidateAutopilotDevice(true);
} else {
onBeginConsent('autopilot');
}
}
}}
disabled={saving || autopilotConsentInProgress}
@@ -51,10 +71,46 @@ export default function AutopilotValidationSection({
</button>
</label>
{/* Corporate Identifier Validation toggle (Preview only) */}
{previewMode && (
<label className="flex items-start justify-between gap-4">
<div>
<p className="font-medium text-gray-900">
Enable Corporate Identifier Validation
<span className="ml-2 text-[10px] font-medium text-indigo-500 bg-indigo-50 px-1.5 py-0.5 rounded">Preview</span>
</p>
<p className="text-sm text-gray-500">
Validates devices against Intune Corporate Device Identifiers (manufacturer + model + serial number).
Uses the <strong>DeviceManagementServiceConfig.ReadWrite.All</strong> permission.
</p>
</div>
<button
onClick={() => {
if (validateCorporateIdentifier) {
setValidateCorporateIdentifier(false);
} else {
// If autopilot is already on, consent exists — just enable
if (validateAutopilotDevice) {
setValidateCorporateIdentifier(true);
} else {
onBeginConsent('corporate');
}
}
}}
disabled={saving || autopilotConsentInProgress}
className={`relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${validateCorporateIdentifier ? 'bg-green-600' : 'bg-gray-300'}`}
>
<span className={`inline-block h-6 w-6 transform rounded-full bg-white transition-transform ${validateCorporateIdentifier ? 'translate-x-7' : 'translate-x-1'}`} />
</button>
</label>
)}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-900">
<strong>Important:</strong> If this is disabled, backend agent endpoints reject requests for this tenant.
Use this toggle and complete admin consent first.
<strong>Important:</strong>{" "}
{previewMode
? "If both validations are disabled, backend agent endpoints reject requests for this tenant. Enable at least one and complete admin consent first."
: "If this is disabled, backend agent endpoints reject requests for this tenant. Use this toggle and complete admin consent first."}
</p>
</div>
@@ -31,6 +31,7 @@ export interface TenantConfiguration {
manufacturerWhitelist: string;
modelWhitelist: string;
validateAutopilotDevice: boolean;
validateCorporateIdentifier?: boolean;
allowInsecureAgentRequests?: boolean;
dataRetentionDays: number;
sessionTimeoutHours: number;
@@ -101,6 +102,7 @@ export default function SettingsPage() {
const [manufacturerWhitelist, setManufacturerWhitelist] = useState("Dell*,HP*,Lenovo*,Microsoft Corporation");
const [modelWhitelist, setModelWhitelist] = useState("*");
const [validateAutopilotDevice, setValidateAutopilotDevice] = useState(false);
const [validateCorporateIdentifier, setValidateCorporateIdentifier] = useState(false);
const [dataRetentionDays, setDataRetentionDays] = useState(90);
const [sessionTimeoutHours, setSessionTimeoutHours] = useState(5);
@@ -110,6 +112,12 @@ export default function SettingsPage() {
const [helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds] = useState(30);
const [autopilotConsentInProgress, setAutopilotConsentInProgress] = useState(false);
// Preview mode (seeded via ?preview=1 in Navbar, persisted in localStorage)
const [previewMode, setPreviewMode] = useState(() => {
if (typeof window !== 'undefined') return localStorage.getItem('previewMode') === 'true';
return false;
});
// Agent behavior state
const [selfDestructOnComplete, setSelfDestructOnComplete] = useState(true);
const [keepLogFile, setKeepLogFile] = useState(false);
@@ -138,6 +146,17 @@ export default function SettingsPage() {
const [localAdminAllowedAccounts, setLocalAdminAllowedAccounts] = useState<string[]>([]);
const [newAllowedAccount, setNewAllowedAccount] = useState("");
// Listen for previewMode changes from Navbar
useEffect(() => {
const handler = () => setPreviewMode(localStorage.getItem('previewMode') === 'true');
window.addEventListener('storage', handler);
window.addEventListener('localStorageChange', handler);
return () => {
window.removeEventListener('storage', handler);
window.removeEventListener('localStorageChange', handler);
};
}, []);
// Derived: true when form differs from last-saved config
const hasUnsavedChanges = useMemo(() => {
if (!config) return false;
@@ -145,6 +164,7 @@ export default function SettingsPage() {
manufacturerWhitelist !== config.manufacturerWhitelist ||
modelWhitelist !== config.modelWhitelist ||
validateAutopilotDevice !== config.validateAutopilotDevice ||
validateCorporateIdentifier !== (config.validateCorporateIdentifier ?? false) ||
dataRetentionDays !== config.dataRetentionDays ||
sessionTimeoutHours !== config.sessionTimeoutHours ||
enablePerformanceCollector !== config.enablePerformanceCollector ||
@@ -172,7 +192,7 @@ export default function SettingsPage() {
);
}, [
config,
manufacturerWhitelist, modelWhitelist, validateAutopilotDevice,
manufacturerWhitelist, modelWhitelist, validateAutopilotDevice, validateCorporateIdentifier,
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector,
performanceCollectorInterval, helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile,
rebootOnComplete, rebootDelaySeconds, enableGeoLocation, enableImeMatchLog,
@@ -213,6 +233,7 @@ export default function SettingsPage() {
setManufacturerWhitelist(data.manufacturerWhitelist);
setModelWhitelist(data.modelWhitelist);
setValidateAutopilotDevice(data.validateAutopilotDevice);
setValidateCorporateIdentifier(data.validateCorporateIdentifier ?? false);
setDataRetentionDays(data.dataRetentionDays ?? 90);
setSessionTimeoutHours(data.sessionTimeoutHours ?? 5);
setEnablePerformanceCollector(data.enablePerformanceCollector ?? true);
@@ -415,7 +436,7 @@ export default function SettingsPage() {
pendingNavigationRef.current = null;
};
const saveConfiguration = async (validateAutopilotDeviceOverride?: boolean) => {
const saveConfiguration = async (validateAutopilotDeviceOverride?: boolean, validateCorporateIdentifierOverride?: boolean) => {
if (!tenantId || !config) return;
try {
@@ -424,12 +445,14 @@ export default function SettingsPage() {
setSuccessMessage(null);
const autopilotDeviceValidationValue = validateAutopilotDeviceOverride ?? validateAutopilotDevice;
const corporateIdentifierValidationValue = validateCorporateIdentifierOverride ?? validateCorporateIdentifier;
const updatedConfig: TenantConfiguration = {
...config,
manufacturerWhitelist,
modelWhitelist,
validateAutopilotDevice: autopilotDeviceValidationValue,
validateCorporateIdentifier: corporateIdentifierValidationValue,
dataRetentionDays,
sessionTimeoutHours,
enablePerformanceCollector,
@@ -474,6 +497,7 @@ export default function SettingsPage() {
const result = await response.json();
setConfig(result.config);
setValidateAutopilotDevice(result.config.validateAutopilotDevice);
setValidateCorporateIdentifier(result.config.validateCorporateIdentifier ?? false);
setSuccessMessage("Configuration saved successfully!");
setTimeout(() => setSuccessMessage(null), 3000);
} finally {
@@ -481,7 +505,7 @@ export default function SettingsPage() {
}
};
const beginAutopilotDeviceValidationEnableFlow = async () => {
const beginDeviceValidationConsentFlow = async (trigger: 'autopilot' | 'corporate') => {
if (!tenantId) return;
try {
@@ -514,7 +538,8 @@ export default function SettingsPage() {
throw new Error("Backend did not return a consent URL.");
}
sessionStorage.setItem("autopilotDeviceValidationPending", "true");
sessionStorage.setItem("deviceValidationConsentPending", "true");
sessionStorage.setItem("consentTrigger", trigger);
window.location.href = data.consentUrl;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start admin consent flow");
@@ -526,8 +551,10 @@ export default function SettingsPage() {
const handleConsentCallback = async () => {
if (!tenantId || !config) return;
const wasPending = sessionStorage.getItem("autopilotDeviceValidationPending") === "true";
if (!wasPending) return;
// Support both old and new sessionStorage keys for backward compatibility
const wasPendingNew = sessionStorage.getItem("deviceValidationConsentPending") === "true";
const wasPendingOld = sessionStorage.getItem("autopilotDeviceValidationPending") === "true";
if (!wasPendingNew && !wasPendingOld) return;
const queryParams = new URLSearchParams(window.location.search);
const adminConsent = queryParams.get("admin_consent");
@@ -538,7 +565,10 @@ export default function SettingsPage() {
return;
}
const trigger = sessionStorage.getItem("consentTrigger") ?? "autopilot";
sessionStorage.removeItem("deviceValidationConsentPending");
sessionStorage.removeItem("autopilotDeviceValidationPending");
sessionStorage.removeItem("consentTrigger");
if (consentError) {
const errorText = consentErrorDescription
@@ -576,8 +606,13 @@ export default function SettingsPage() {
throw new Error(statusData.message || "Consent is not active yet for this tenant.");
}
await saveConfiguration(true);
setSuccessMessage("Autopilot Device Validation enabled. Backend agent endpoints are now unlocked for this tenant.");
if (trigger === "corporate") {
await saveConfiguration(undefined, true);
setSuccessMessage("Corporate Identifier Validation enabled. Backend agent endpoints are now unlocked for this tenant.");
} else {
await saveConfiguration(true);
setSuccessMessage("Autopilot Device Validation enabled. Backend agent endpoints are now unlocked for this tenant.");
}
router.replace("/settings");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to verify consent");
@@ -848,9 +883,12 @@ export default function SettingsPage() {
<AutopilotValidationSection
validateAutopilotDevice={validateAutopilotDevice}
setValidateAutopilotDevice={setValidateAutopilotDevice}
validateCorporateIdentifier={validateCorporateIdentifier}
setValidateCorporateIdentifier={setValidateCorporateIdentifier}
autopilotConsentInProgress={autopilotConsentInProgress}
saving={saving}
onBeginConsent={beginAutopilotDeviceValidationEnableFlow}
onBeginConsent={beginDeviceValidationConsentFlow}
previewMode={previewMode}
/>
{user?.isTenantAdmin && (