diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59aa369b..347efdbb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -59,6 +59,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g
- `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`).
### Fixed
+- `setup requirements` no longer fails against a correctly configured custom client app, and no longer reports the app as missing when Microsoft Graph cannot complete the lookup (#489).
- Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489).
- The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489).
- `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460).
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs
index 3e0717f8..66a20448 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using Microsoft.Agents.A365.DevTools.Cli.Constants;
+using System.Globalization;
namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions;
@@ -51,6 +52,45 @@ public static ClientAppValidationException AppNotFound(string clientAppId, strin
});
}
+ ///
+ /// Creates an exception when the client app lookup could not complete. Distinct from
+ /// : absence is only proven by a successful Graph response with no
+ /// matching application, never by an authorization, HTTP, or network failure.
+ ///
+ public static ClientAppValidationException ApplicationLookupFailed(
+ string clientAppId,
+ string tenantId,
+ string reason,
+ int statusCode = 0)
+ {
+ var mitigationSteps = new List();
+ if (statusCode == 403)
+ {
+ mitigationSteps.Add($"Ask a tenant administrator to grant your account the '{AuthenticationConstants.ApplicationReadAllScope}' Microsoft Graph permission, or run the command as a Global Administrator or Application Administrator.");
+ }
+
+ mitigationSteps.Add("Confirm you are signed in to the intended tenant with 'az login --tenant '.");
+ mitigationSteps.Add("Confirm network connectivity to Microsoft Graph and retry — the failure may be transient.");
+ mitigationSteps.Add("Do not change 'clientAppId' in a365.config.json based on this error; the app was never confirmed absent.");
+ mitigationSteps.Add($"See setup guide: {ConfigConstants.Agent365CliDocumentationUrl}");
+
+ return new ClientAppValidationException(
+ issueDescription: "Unable to verify the client app registration in the tenant",
+ errorDetails: new List
+ {
+ reason,
+ $"Application lookup for client app '{clientAppId}' failed in tenant '{tenantId}'.",
+ "The lookup did not complete, so the app's presence or absence is unknown."
+ },
+ mitigationSteps: mitigationSteps,
+ context: new Dictionary
+ {
+ ["clientAppId"] = clientAppId,
+ ["tenantId"] = tenantId,
+ ["statusCode"] = statusCode.ToString(CultureInfo.InvariantCulture)
+ });
+ }
+
///
/// Creates exception for missing permissions.
///
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs
index 0076c53a..d67471f0 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs
@@ -267,7 +267,9 @@ public async Task EnsureValidClientAppAsync(
if (provisioned)
{
- // Re-fetch fresh app info and re-validate to confirm provisioning succeeded
+ // Re-fetch fresh app info and re-validate to confirm provisioning succeeded.
+ // A null result now means only that the app was deleted between the two reads;
+ // a failed re-read throws rather than silently keeping the stale verdict.
var freshAppInfo = await GetClientAppInfoAsync(clientAppId, tenantId, ct);
if (freshAppInfo != null)
{
@@ -464,7 +466,7 @@ public async Task EnsureRedirectUrisAsync(
{
_logger.LogDebug("Checking redirect URIs for client app {ClientAppId}", clientAppId);
- using var appDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var appDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,publicClient", ct);
if (appDoc == null)
@@ -519,7 +521,7 @@ public async Task EnsureRedirectUrisAsync(
foreach (var uri in allUris)
urisArray.Add(JsonValue.Create(uri));
- var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId,
+ var patchSuccess = await DirectoryPatchAsync(tenantId,
$"/v1.0/applications/{objectId}",
new JsonObject { ["publicClient"] = new JsonObject { ["redirectUris"] = urisArray } },
ct);
@@ -611,7 +613,7 @@ private async Task EnsureWidsOptionalClaimAsync(
"(required so the CLI can detect Global Administrator role and apply tenant-wide consent grants).");
_logger.LogInformation("Re-run 'a365 setup requirements' at any time to re-verify this setting.");
- var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId,
+ var patchSuccess = await DirectoryPatchAsync(tenantId,
$"/v1.0/applications/{objectId}",
patchPayload,
ct);
@@ -648,8 +650,8 @@ private enum ProbeResult { Admin, NotAdmin, Inconclusive }
/// Attempts to add the 'wids' optional claim and reports admin authority based on the result.
/// Used only when returns Unknown — i.e.
/// the token can't tell us the role because wids isn't configured yet. A successful PATCH
- /// implies admin (Application.ReadWrite-scope-on-app via directory role); a 403 with
- /// Authorization_RequestDenied implies non-admin; anything else is inconclusive.
+ /// implies write authority over the app registration (a directory role, or app ownership);
+ /// a 403 with Authorization_RequestDenied implies non-admin; anything else is inconclusive.
///
private async Task TryProbeAdminViaWidsPatchAsync(
string clientAppId,
@@ -697,7 +699,7 @@ private async Task EnsurePublicClientFlowsEnabledAsync(
{
_logger.LogDebug("Checking 'Allow public client flows' for client app {ClientAppId}", clientAppId);
- using var appDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var appDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,isFallbackPublicClient", ct);
if (appDoc == null)
@@ -737,7 +739,7 @@ private async Task EnsurePublicClientFlowsEnabledAsync(
"headless environments, and as a Conditional Access Policy fallback on Windows).");
_logger.LogInformation("Run 'a365 setup requirements' at any time to re-verify and auto-fix this setting.");
- var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId,
+ var patchSuccess = await DirectoryPatchAsync(tenantId,
$"/v1.0/applications/{objectId}",
new { isFallbackPublicClient = true },
ct);
@@ -858,7 +860,7 @@ private async Task EnsurePermissionsConfiguredAsync(
});
}
- var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId,
+ var patchSuccess = await DirectoryPatchAsync(tenantId,
$"/v1.0/applications/{appInfo.ObjectId}",
new JsonObject { ["requiredResourceAccess"] = updatedResourceAccess },
ct);
@@ -898,7 +900,7 @@ private async Task TryExtendConsentGrantScopesAsync(
try
{
// Look up the service principal for the client app
- using var spDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var spDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct);
if (spDoc == null) return;
@@ -908,7 +910,7 @@ private async Task TryExtendConsentGrantScopesAsync(
if (string.IsNullOrWhiteSpace(spObjectId)) return;
// Find the oauth2PermissionGrant that targets Microsoft Graph
- using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var grantsDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct);
if (grantsDoc == null) return;
@@ -919,7 +921,7 @@ private async Task TryExtendConsentGrantScopesAsync(
// Look up the Microsoft Graph service principal ID to match against resourceId
string? graphSpObjectId = null;
- using var graphSpDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var graphSpDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/servicePrincipals?$filter=appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'&$select=id", ct);
if (graphSpDoc != null)
@@ -953,7 +955,7 @@ private async Task TryExtendConsentGrantScopesAsync(
var updatedScope = string.Join(' ', existingScopes.Concat(scopesToAdd));
- var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId,
+ var patchSuccess = await DirectoryPatchAsync(tenantId,
$"/v1.0/oauth2PermissionGrants/{grantId}",
new JsonObject
{
@@ -1071,7 +1073,7 @@ private async Task> CollectMissingRedirectUrisAsync(
{
try
{
- using var appDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var appDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,publicClient", ct);
if (appDoc == null) return new List();
@@ -1111,7 +1113,7 @@ private async Task IsPublicClientFlowsDisabledAsync(
{
try
{
- using var appDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var appDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,isFallbackPublicClient", ct);
if (appDoc == null) return false;
@@ -1148,7 +1150,7 @@ private async Task IsPublicClientFlowsDisabledAsync(
{
try
{
- using var appDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var appDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,optionalClaims", ct);
if (appDoc == null) return (false, null, null);
@@ -1203,7 +1205,7 @@ private async Task HasPrincipalOnlyConsentGrantAsync(string clientAppId, s
{
try
{
- using var spDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var spDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct);
if (spDoc == null) return false;
@@ -1211,7 +1213,7 @@ private async Task HasPrincipalOnlyConsentGrantAsync(string clientAppId, s
var spObjectId = spJson?["value"]?.AsArray().FirstOrDefault()?.AsObject()["id"]?.GetValue();
if (string.IsNullOrWhiteSpace(spObjectId)) return false;
- using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var grantsDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct);
if (grantsDoc == null) return false;
@@ -1258,7 +1260,7 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s
{
try
{
- using var spDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var spDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct);
if (spDoc == null) return;
@@ -1266,7 +1268,7 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s
var spObjectId = spJson?["value"]?.AsArray().FirstOrDefault()?.AsObject()["id"]?.GetValue();
if (string.IsNullOrWhiteSpace(spObjectId)) return;
- using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var grantsDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct);
if (grantsDoc == null) return;
@@ -1294,7 +1296,7 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s
_logger.LogInformation("Upgrading consent grant from per-user to tenant-wide (AllPrincipals)...");
- var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId,
+ var patchSuccess = await DirectoryPatchAsync(tenantId,
$"/v1.0/oauth2PermissionGrants/{grantId}",
new JsonObject
{
@@ -1318,45 +1320,111 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s
#region Private Helper Methods
+ // Tenant-directory reads and repairs must run as the ambient operator identity. A token
+ // issued for the app under validation carries only User.Read, so Graph rejects every
+ // application and servicePrincipal query with 403 Authorization_RequestDenied (issue #489).
+ private Task DirectoryGetAsync(string tenantId, string relativePath, CancellationToken ct) =>
+ _graphApiService.GraphGetAsync(tenantId, relativePath, ct,
+ authenticationMode: GraphAuthenticationMode.Ambient);
+
+ private Task DirectoryPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct) =>
+ _graphApiService.GraphPatchAsync(tenantId, relativePath, payload, ct,
+ authenticationMode: GraphAuthenticationMode.Ambient);
+
+ private Task DirectoryGetWithResponseAsync(string tenantId, string relativePath, CancellationToken ct) =>
+ _graphApiService.GraphGetWithResponseAsync(tenantId, relativePath, ct: ct,
+ authenticationMode: GraphAuthenticationMode.Ambient);
+
private async Task GetClientAppInfoAsync(string clientAppId, string tenantId, CancellationToken ct)
{
_logger.LogDebug("Checking if client app exists in tenant...");
const string path = "/v1.0/applications?$filter=appId eq '{0}'&$select=id,appId,displayName,requiredResourceAccess";
- var graphResponse = await _graphApiService.GraphGetWithResponseAsync(tenantId,
- string.Format(path, clientAppId), ct: ct);
- if (graphResponse == null || !graphResponse.IsSuccess)
+ // Probe with the ambient bootstrap identity: a token issued for the app under validation
+ // cannot prove that app's own absence, and it is not consented for Application.Read.All (issue #489).
+ GraphApiService.GraphResponse graphResponse;
+ try
{
- // Only retry on 401 — a stale token due to CAE revocation. Transient errors (503,
- // network failure) surface the real error to the caller rather than masking it as
- // "token revoked". StatusCode 0 means token acquisition itself failed.
- if (graphResponse?.StatusCode != 401)
+ graphResponse = await _graphApiService.GraphGetWithResponseAsync(tenantId,
+ string.Format(path, clientAppId), ct: ct,
+ authenticationMode: GraphAuthenticationMode.Ambient);
+
+ if (graphResponse is { IsSuccess: false, StatusCode: 401 })
{
- _logger.LogDebug("Graph app query failed with {StatusCode} — not retrying", graphResponse?.StatusCode);
- return null;
+ _logger.LogDebug("Graph app query returned 401 — retrying with fresh token (possible CAE revocation)");
+ graphResponse.Json?.Dispose();
+ graphResponse = await _graphApiService.GraphGetWithResponseAsync(tenantId,
+ string.Format(path, clientAppId), forceRefresh: true, ct: ct,
+ authenticationMode: GraphAuthenticationMode.Ambient);
+
+ // Only a second 401 proves revocation. Any other failure falls through so the
+ // block below reports it with its real status instead of misdiagnosing it.
+ if (graphResponse is { IsSuccess: false, StatusCode: 401 })
+ throw ClientAppValidationException.TokenRevoked(clientAppId);
}
+ }
+ catch (Exception ex) when (ex is not ClientAppValidationException
+ && !(ex is OperationCanceledException && ct.IsCancellationRequested))
+ {
+ // An HttpClient timeout arrives as TaskCanceledException with no cancellation
+ // requested; that is a lookup failure, not a caller cancel.
+ throw ClientAppValidationException.ApplicationLookupFailed(clientAppId, tenantId, ex.Message);
+ }
- _logger.LogDebug("Graph app query returned 401 — retrying with fresh token (possible CAE revocation)");
- graphResponse = await _graphApiService.GraphGetWithResponseAsync(tenantId,
- string.Format(path, clientAppId), forceRefresh: true, ct: ct);
-
- if (!graphResponse.IsSuccess)
- throw ClientAppValidationException.TokenRevoked(clientAppId);
+ if (graphResponse is null || !graphResponse.IsSuccess)
+ {
+ // 403, 429, 5xx, network failure and token-acquisition failure all leave the app's
+ // existence unknown. Reporting them as "not found" sends users to re-create an app
+ // that is already there.
+ var status = graphResponse is null
+ ? "Microsoft Graph application lookup returned no result."
+ : graphResponse.StatusCode > 0
+ ? $"Microsoft Graph application lookup failed: HTTP {graphResponse.StatusCode} {graphResponse.ReasonPhrase}".TrimEnd()
+ : $"Microsoft Graph application lookup failed before a response was received: {graphResponse.ReasonPhrase}".TrimEnd();
+
+ _logger.LogDebug("Graph app query failed with {StatusCode} — reporting lookup failure", graphResponse?.StatusCode ?? 0);
+ throw ClientAppValidationException.ApplicationLookupFailed(
+ clientAppId, tenantId, status, graphResponse?.StatusCode ?? 0);
}
using var doc = graphResponse.Json;
- if (doc == null) return null;
+ var apps = doc is null ? null : JsonNode.Parse(doc.RootElement.GetRawText()) as JsonObject;
+ if (apps?["value"] is not JsonArray values)
+ {
+ throw ClientAppValidationException.ApplicationLookupFailed(
+ clientAppId,
+ tenantId,
+ "Microsoft Graph returned an invalid application lookup response.",
+ graphResponse.StatusCode);
+ }
+
+ // Only a successful response with no match proves the app is absent.
+ if (values.Count == 0) return null;
+
+ if (values[0] is not JsonObject app)
+ {
+ throw ClientAppValidationException.ApplicationLookupFailed(
+ clientAppId,
+ tenantId,
+ "Microsoft Graph returned an application result that is not an object.",
+ graphResponse.StatusCode);
+ }
- var response = JsonNode.Parse(doc.RootElement.GetRawText());
- var apps = response?["value"]?.AsArray();
- if (apps == null || apps.Count == 0) return null;
+ if (app["id"] is not JsonValue idValue || !idValue.TryGetValue(out var objectId) || string.IsNullOrWhiteSpace(objectId))
+ {
+ throw ClientAppValidationException.ApplicationLookupFailed(
+ clientAppId,
+ tenantId,
+ "Microsoft Graph returned an application result without a valid object ID.",
+ graphResponse.StatusCode);
+ }
+
+ var displayName = app["displayName"] is JsonValue nameValue && nameValue.TryGetValue(out var name)
+ ? name
+ : string.Empty;
- var app = apps[0]!.AsObject();
- return new ClientAppInfo(
- app["id"]?.GetValue() ?? string.Empty,
- app["displayName"]?.GetValue() ?? string.Empty,
- app["requiredResourceAccess"]?.AsArray());
+ return new ClientAppInfo(objectId, displayName, app["requiredResourceAccess"] as JsonArray);
}
private async Task> ValidatePermissionsConfiguredAsync(
@@ -1438,7 +1506,7 @@ private async Task> ResolvePermissionIdsAsync(string
try
{
- using var doc = await _graphApiService.GraphGetAsync(tenantId,
+ using var doc = await DirectoryGetAsync(tenantId,
$"/v1.0/servicePrincipals?$filter=appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'&$select=id,oauth2PermissionScopes",
ct);
@@ -1498,7 +1566,7 @@ private async Task> GetConsentedPermissionsAsync(string clientAp
try
{
// Get service principal for the app
- using var spDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var spDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct);
if (spDoc == null)
@@ -1529,8 +1597,8 @@ private async Task> GetConsentedPermissionsAsync(string clientAp
// are consented rather than reporting an empty set (which would trigger a false
// "permissions not consented" prompt for non-admin developers who can never read
// the grants table by design).
- var grantsResp = await _graphApiService.GraphGetWithResponseAsync(tenantId,
- $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct: ct);
+ var grantsResp = await DirectoryGetWithResponseAsync(tenantId,
+ $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct);
using var grantsDoc = grantsResp.Json;
if (grantsResp.StatusCode == 403)
@@ -1586,7 +1654,7 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string te
_logger.LogDebug("Checking admin consent status for {ClientAppId}", clientAppId);
// Get service principal for the app
- using var spDoc = await _graphApiService.GraphGetAsync(tenantId,
+ using var spDoc = await DirectoryGetAsync(tenantId,
$"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id,appId", ct);
if (spDoc == null)
@@ -1617,8 +1685,8 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string te
// "caller lacks DelegatedPermissionGrant.Read.All" (403) from other failure modes
// (token acquisition, network, 5xx) — the user-facing message differs and lumping them
// together would either misattribute the cause or hide real failures.
- var grantsResp = await _graphApiService.GraphGetWithResponseAsync(tenantId,
- $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct: ct);
+ var grantsResp = await DirectoryGetWithResponseAsync(tenantId,
+ $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct);
using var grantsDoc = grantsResp.Json;
if (grantsResp.StatusCode == 403)
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs
index 23fc92d8..83c91fa1 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs
@@ -363,9 +363,9 @@ public virtual async Task ServicePrincipalExistsAsync(string tenantId, str
/// Executes a GET request to Microsoft Graph API.
/// Virtual to allow mocking in unit tests using Moq.
///
- public virtual async Task GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable? scopes = null)
+ public virtual async Task GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable? scopes = null, GraphAuthenticationMode authenticationMode = GraphAuthenticationMode.ResolvedClientApp)
{
- if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct)) return null;
+ if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct, authenticationMode: authenticationMode)) return null;
var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath);
try
{
@@ -553,9 +553,9 @@ public virtual async Task GraphPostWithResponseAsync(string tenan
/// Executes a PATCH request to Microsoft Graph API.
/// Virtual to allow mocking in unit tests using Moq.
///
- public virtual async Task GraphPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null)
+ public virtual async Task GraphPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null, GraphAuthenticationMode authenticationMode = GraphAuthenticationMode.ResolvedClientApp)
{
- if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct)) return false;
+ if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct, authenticationMode: authenticationMode)) return false;
var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath);
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
try
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphAuthenticationMode.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphAuthenticationMode.cs
index 2c07aa97..1fea04a4 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphAuthenticationMode.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphAuthenticationMode.cs
@@ -16,8 +16,10 @@ public enum GraphAuthenticationMode
///
/// Force the ambient bootstrap identity, ignoring the resolved client app and any requested
- /// scopes. Required when probing whether a client app exists: authenticating as the app being
- /// probed makes its own absence unverifiable.
+ /// scopes. Required whenever the operation reads or repairs tenant directory objects for the
+ /// client app: a token issued for that app carries only its own default scope, so Graph
+ /// refuses application and servicePrincipal queries. Note that requested scopes are silently
+ /// discarded on this path, so callers needing a specific scope must not use it.
///
Ambient = 1
}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs
index 47642862..e16e73cf 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs
@@ -82,7 +82,7 @@ await _clientAppValidator.EnsureValidClientAppAsync(
return RequirementCheckResult.Failure(
errorMessage: string.Join("\n", errorLines),
resolutionGuidance: string.Join("\n", ex.MitigationSteps),
- details: $"Client app validation failed for {config.ClientAppId}. Please ensure the app exists and has the required configuration."
+ details: $"Client app validation failed for {config.ClientAppId}."
);
}
catch (OperationCanceledException)
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs
index a2ee275f..1f075857 100644
--- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs
@@ -798,7 +798,8 @@ public override Task GraphPatchAsync(
string relativePath,
object payload,
CancellationToken ct = default,
- IEnumerable? scopes = null)
+ IEnumerable? scopes = null,
+ GraphAuthenticationMode authenticationMode = GraphAuthenticationMode.ResolvedClientApp)
=> Task.FromException(new HttpRequestException("Network error during PATCH"));
}
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorAmbientIdentityTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorAmbientIdentityTests.cs
new file mode 100644
index 00000000..a1172de4
--- /dev/null
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorAmbientIdentityTests.cs
@@ -0,0 +1,149 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using FluentAssertions;
+using Microsoft.Agents.A365.DevTools.Cli.Constants;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using System.Net;
+using System.Text;
+using Xunit;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services;
+
+///
+/// End-to-end guard for issue #489: once the bootstrap resolves a tenant-owned client app,
+/// every directory read and repair in client app validation must run as the ambient operator
+/// identity. A token issued for the app under validation carries only User.Read, so Microsoft
+/// Graph answers 403 Authorization_RequestDenied to application and servicePrincipal queries.
+///
+public class ClientAppValidatorAmbientIdentityTests
+{
+ private const string CustomAppId = "11111111-2222-3333-4444-555555555555";
+ private const string TenantId = "12345678-1234-1234-1234-123456789012";
+ private const string AmbientToken = "ambient-identity-token";
+ private const string CustomAppToken = "custom-app-user-read-token";
+
+ ///
+ /// Ambient identity can read the directory, while a token minted for the custom client app
+ /// is refused by Graph.
+ ///
+ private sealed class DualIdentityGraphHandler : HttpMessageHandler
+ {
+ public List RequestsAsCustomApp { get; } = new();
+ public List RequestsAsAmbient { get; } = new();
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ var url = request.RequestUri!.ToString();
+ var descriptor = $"{request.Method} {request.RequestUri!.AbsolutePath}";
+
+ if (request.Headers.Authorization?.Parameter == CustomAppToken)
+ {
+ RequestsAsCustomApp.Add(descriptor);
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Forbidden)
+ {
+ Content = new StringContent(
+ """{"error":{"code":"Authorization_RequestDenied","message":"Insufficient privileges to complete the operation."}}""",
+ Encoding.UTF8, "application/json")
+ });
+ }
+
+ RequestsAsAmbient.Add(descriptor);
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(BuildBody(url), Encoding.UTF8, "application/json")
+ });
+ }
+
+ private static string BuildBody(string url)
+ {
+ if (url.Contains("oauth2PermissionScopes", StringComparison.OrdinalIgnoreCase))
+ {
+ var scopes = string.Join(",", AuthenticationConstants.RequiredClientAppPermissions
+ .Select((p, i) => $$"""{"id":"{{PermissionId(i)}}","value":"{{p}}"}"""));
+ return $$"""{"value":[{"id":"graph-sp","oauth2PermissionScopes":[{{scopes}}]}]}""";
+ }
+
+ if (url.Contains("/applications", StringComparison.OrdinalIgnoreCase))
+ {
+ var resourceAccess = string.Join(",", AuthenticationConstants.RequiredClientAppPermissions
+ .Select((_, i) => $$"""{"id":"{{PermissionId(i)}}","type":"Scope"}"""));
+ var redirectUris = string.Join(",", AuthenticationConstants
+ .GetRequiredRedirectUris(CustomAppId).Select(u => $"\"{u}\""));
+ return $$"""
+ {"value":[{
+ "id":"app-object-id",
+ "appId":"{{CustomAppId}}",
+ "displayName":"Agent 365 CLI",
+ "isFallbackPublicClient":true,
+ "publicClient":{"redirectUris":[{{redirectUris}}]},
+ "optionalClaims":{"accessToken":[{"name":"wids"}]},
+ "requiredResourceAccess":[{"resourceAppId":"{{AuthenticationConstants.MicrosoftGraphResourceAppId}}","resourceAccess":[{{resourceAccess}}]}]
+ }]}
+ """;
+ }
+
+ return """{"value":[]}""";
+ }
+
+ private static string PermissionId(int index) => $"aaaa{index:0000}-0000-0000-0000-000000000000";
+ }
+
+ private static GraphApiService CreateGraphService(DualIdentityGraphHandler handler)
+ {
+ var authService = Substitute.For();
+ authService.GetAccessTokenAsync(Arg.Any(), Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(AmbientToken));
+
+ var tokenProvider = Substitute.For();
+ tokenProvider.GetMgGraphAccessTokenAsync(
+ Arg.Any(), Arg.Any>(), Arg.Any(),
+ Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(CustomAppToken));
+
+ return new GraphApiService(
+ NullLogger.Instance,
+ Substitute.For(Substitute.For>()),
+ authService, handler, tokenProvider,
+ loginHintResolver: () => Task.FromResult(null),
+ retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0))
+ {
+ // Exactly what RequirementsSubcommand does once the bootstrap resolves the app.
+ CustomClientAppId = CustomAppId
+ };
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenCustomAppTokenIsRefusedByGraph_StillCompletesValidation()
+ {
+ using var handler = new DualIdentityGraphHandler();
+ var validator = new ClientAppValidator(NullLogger.Instance, CreateGraphService(handler));
+
+ var act = async () => await validator.EnsureValidClientAppAsync(
+ CustomAppId, TenantId, skipConfirmation: true);
+
+ await act.Should().NotThrowAsync(
+ because: "the app exists and is fully configured, so a 403 on the custom-app token must not fail setup");
+ handler.RequestsAsCustomApp.Should().BeEmpty(
+ because: "no directory read or repair may authenticate as the app under validation — that token only carries User.Read");
+ handler.RequestsAsAmbient.Should().NotBeEmpty(
+ because: "the validation flow must reach Graph using the operator's ambient identity");
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenAppIsFullyConfigured_MakesNoRepairWrites()
+ {
+ using var handler = new DualIdentityGraphHandler();
+ var validator = new ClientAppValidator(NullLogger.Instance, CreateGraphService(handler));
+
+ await validator.EnsureValidClientAppAsync(CustomAppId, TenantId, skipConfirmation: true);
+
+ handler.RequestsAsAmbient.Should().NotContain(r => r.StartsWith("PATCH", StringComparison.Ordinal),
+ because: "an app that already carries every required permission, redirect URI, the public-client flag and the wids claim needs no repair write");
+ }
+}
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs
index c516b006..c062672f 100644
--- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs
@@ -8,6 +8,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
+using System.Globalization;
using System.Text.Json;
using Xunit;
@@ -46,6 +47,13 @@ public class ClientAppValidatorTests
// so it does not conflict with SetupAdminConsentSp / SetupAdminConsentGrantsEmpty.
private const string ConsentSpObjId = "consent-check-sp-id-999";
+ // Pinning the exact issue descriptions keeps "inconclusive lookup" and "confirmed absent"
+ // distinguishable: asserting only the absence of "not found" is satisfied by every other
+ // failure factory in ClientAppValidationException.
+ private const string AppNotFoundIssue = "Client app not found in tenant";
+ private const string LookupFailedIssue = "Unable to verify the client app registration in the tenant";
+ private const string TokenRevokedIssue = "Azure authentication token revoked — re-authentication required";
+
public ClientAppValidatorTests()
{
_logger = Substitute.For>();
@@ -122,8 +130,11 @@ public async Task EnsureValidClientAppAsync_WhenAppDoesNotExist_ThrowsClientAppV
{
SetupAppInfoGetEmpty();
- await Assert.ThrowsAsync(async () =>
+ var exception = await Assert.ThrowsAsync(async () =>
await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ exception.IssueDescription.Should().Be(AppNotFoundIssue,
+ because: "a successful Graph response with an empty result set is the only proof that the app is absent");
}
[Fact]
@@ -131,26 +142,300 @@ public async Task EnsureValidClientAppAsync_WhenGraphQueryFails_ThrowsClientAppV
{
// Simulate a 401 on both the first attempt and the retry after cache invalidation.
// TokenRevoked is only thrown when the failure is specifically a 401 (auth error),
- // not for transient failures like 503 — which would produce AppNotFound instead.
+ // not for transient failures like 503 — which report an inconclusive lookup instead.
+ SetupAppInfoGetFailure(401, "Unauthorized");
+
+ var exception = await Assert.ThrowsAsync(
+ () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed);
+ exception.IssueDescription.Should().Be(TokenRevokedIssue,
+ because: "a persistent 401 from Graph indicates a CAE token revocation, not a transient error");
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenApplicationLookupIsForbidden_DoesNotReportAppNotFound()
+ {
+ // Regression (#489): reading application metadata requires Application.Read.All. A 403
+ // leaves the app's existence unknown; reporting "not found" sends operators to re-create
+ // an app registration that is already present in the tenant.
+ SetupAppInfoGetFailure(403, "Forbidden");
+
+ var exception = await Assert.ThrowsAsync(
+ () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ exception.IssueDescription.Should().Be(LookupFailedIssue,
+ because: "HTTP 403 is an authorization failure and must be reported as an inconclusive lookup, never as proof the app is absent");
+ exception.ErrorDetails.Should().Contain(d => d.Contains("403", StringComparison.Ordinal),
+ because: "the HTTP status must be preserved so operators can identify the authorization failure");
+ exception.MitigationSteps.Should().Contain(s => s.Contains(AuthenticationConstants.ApplicationReadAllScope, StringComparison.Ordinal),
+ because: "reading application metadata requires the Application.Read.All Microsoft Graph permission");
+ exception.MitigationSteps.Should().NotContain(s => s.Contains("from scratch", StringComparison.OrdinalIgnoreCase),
+ because: "the app-not-found remediation re-creates the app registration and must never be offered on an unproven absence");
+ exception.Context.Should().Contain(new KeyValuePair("statusCode", "403"),
+ because: "the status must be machine-readable in the exception context, not only embedded in prose");
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenForbiddenAfter401Retry_DoesNotReportTokenRevoked()
+ {
+ // Regression (#489): a stale ambient token yields 401, and the refreshed retry then hits
+ // the real 403. Reporting revocation sends operators to 'az login', which cannot fix a
+ // missing Application.Read.All grant.
+ var attempts = 0;
+ _graphApiService.GraphGetWithResponseAsync(
+ Arg.Any(),
+ Arg.Is(p => p.Contains("displayName")),
+ Arg.Any(),
+ Arg.Any?>(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(_ => Task.FromResult(Interlocked.Increment(ref attempts) == 1
+ ? new GraphApiService.GraphResponse { IsSuccess = false, StatusCode = 401, ReasonPhrase = "Unauthorized" }
+ : new GraphApiService.GraphResponse { IsSuccess = false, StatusCode = 403, ReasonPhrase = "Forbidden" }));
+
+ var exception = await Assert.ThrowsAsync(
+ () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ exception.IssueDescription.Should().Be(LookupFailedIssue,
+ because: "only a second 401 proves token revocation; the retry's own status must be reported instead");
+ exception.ErrorDetails.Should().Contain(d => d.Contains("403", StringComparison.Ordinal),
+ because: "the status returned by the refreshed attempt is the actionable one and must survive into the error");
+ }
+
+ [Theory]
+ [InlineData(429, "Too Many Requests")]
+ [InlineData(500, "Internal Server Error")]
+ [InlineData(503, "Service Unavailable")]
+ public async Task EnsureValidClientAppAsync_WhenApplicationLookupCannotComplete_PreservesStatusAndDoesNotReportAppNotFound(
+ int statusCode, string reasonPhrase)
+ {
+ SetupAppInfoGetFailure(statusCode, reasonPhrase);
+
+ var exception = await Assert.ThrowsAsync(
+ () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ exception.IssueDescription.Should().Be(LookupFailedIssue,
+ because: "throttling and server errors leave the app's existence unknown");
+ exception.ErrorDetails.Should().Contain(d => d.Contains(statusCode.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal),
+ because: "the HTTP status must survive into the error so operators can distinguish throttling from a server fault");
+ exception.MitigationSteps.Should().NotContain(s => s.Contains(AuthenticationConstants.ApplicationReadAllScope, StringComparison.Ordinal),
+ because: "only HTTP 403 indicates a permission gap; suggesting an admin grant for a transient failure misdirects the operator");
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenTokenAcquisitionFailsBeforeResponse_SurfacesTheUnderlyingReason()
+ {
+ // The shape GraphGetWithResponseAsync returns when no HTTP response was ever received.
+ SetupAppInfoGetFailure(0, "NoAuth");
+
+ var exception = await Assert.ThrowsAsync(
+ () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ exception.IssueDescription.Should().Be(LookupFailedIssue,
+ because: "a failure before any response leaves the app's existence unknown");
+ exception.ErrorDetails.Should().Contain(d => d.Contains("NoAuth", StringComparison.Ordinal),
+ because: "with no HTTP status available the reason phrase is the only diagnostic, so it must be preserved");
+ }
+
+ [Theory]
+ [InlineData("""{"unexpected": true}""", "a response without a 'value' array does not prove the application is absent")]
+ [InlineData("""{"value": {}}""", "a non-array 'value' is a malformed response, not a confirmed absence")]
+ [InlineData("""{"value": ["app"]}""", "a non-object array element is a malformed response, not a confirmed absence")]
+ [InlineData("""{"value": [{"displayName": "Test App"}]}""", "an application result without an object ID is unusable, not a confirmed absence")]
+ [InlineData("""{"value": [{"id": 42}]}""", "a non-string object ID is a malformed response, not a confirmed absence")]
+ public async Task EnsureValidClientAppAsync_WhenApplicationLookupResponseIsMalformed_DoesNotReportAppNotFound(
+ string body, string reason)
+ {
_graphApiService.GraphGetWithResponseAsync(
Arg.Any(),
Arg.Is(p => p.Contains("displayName")),
Arg.Any(),
Arg.Any?>(),
- Arg.Any())
+ Arg.Any(),
+ Arg.Any())
.Returns(_ => Task.FromResult(new GraphApiService.GraphResponse
{
- IsSuccess = false,
- StatusCode = 401,
- ReasonPhrase = "Unauthorized"
+ IsSuccess = true,
+ StatusCode = 200,
+ Json = JsonDocument.Parse(body)
}));
var exception = await Assert.ThrowsAsync(
() => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
- exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed);
- exception.IssueDescription.Should().Contain("revoked",
- because: "a persistent 401 from Graph indicates a CAE token revocation, not a transient error");
+ exception.IssueDescription.Should().Be(LookupFailedIssue, because: reason);
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenCustomClientAppIdIsResolved_LooksUpApplicationAmbiently()
+ {
+ // Regression (#489): after the bootstrap resolves a tenant-owned client app,
+ // GraphApiService.CustomClientAppId is set. Authenticating the existence probe as that
+ // app yields a User.Read token that Graph rejects with 403 on /applications.
+ _graphApiService.CustomClientAppId = ValidClientAppId;
+ SetupAppInfoWithAllPermissions(ValidClientAppId);
+ SetupPermissionResolution();
+
+ await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId);
+
+ await _graphApiService.Received().GraphGetWithResponseAsync(
+ Arg.Any(),
+ Arg.Is(p => p.Contains("/v1.0/applications")),
+ Arg.Any(),
+ Arg.Any?>(),
+ Arg.Any(),
+ GraphAuthenticationMode.Ambient);
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenApplicationLookupReturns401_RetriesAmbientlyWithFreshToken()
+ {
+ _graphApiService.CustomClientAppId = ValidClientAppId;
+ SetupAppInfoGetFailure(401, "Unauthorized");
+
+ await Assert.ThrowsAsync(
+ () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ await _graphApiService.Received(1).GraphGetWithResponseAsync(
+ Arg.Any(),
+ Arg.Is(p => p.Contains("/v1.0/applications")),
+ true,
+ Arg.Any?>(),
+ Arg.Any(),
+ GraphAuthenticationMode.Ambient);
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenApplicationLookupSucceedsAfter401Retry_CompletesValidation()
+ {
+ SetupAppInfoWithAllPermissions(ValidClientAppId);
+ SetupPermissionResolution();
+
+ var appJson = BuildAppInfoJson(ValidClientAppId, BuildAllPermissionsResourceAccess());
+ var attempts = 0;
+ _graphApiService.GraphGetWithResponseAsync(
+ Arg.Any(),
+ Arg.Is(p => p.Contains("displayName")),
+ Arg.Any(),
+ Arg.Any?>(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(_ => Task.FromResult(Interlocked.Increment(ref attempts) == 1
+ ? new GraphApiService.GraphResponse { IsSuccess = false, StatusCode = 401, ReasonPhrase = "Unauthorized" }
+ : new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = JsonDocument.Parse(appJson) }));
+
+ await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId);
+
+ await _graphApiService.Received(1).GraphGetWithResponseAsync(
+ Arg.Any(),
+ Arg.Is(p => p.Contains("/v1.0/applications")),
+ true,
+ Arg.Any?>(),
+ Arg.Any(),
+ GraphAuthenticationMode.Ambient);
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenApplicationLookupThrows_ReportsLookupFailureNotAppNotFound()
+ {
+ _graphApiService.GraphGetWithResponseAsync(
+ Arg.Any(),
+ Arg.Is(p => p.Contains("displayName")),
+ Arg.Any(),
+ Arg.Any?>(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(_ => throw new HttpRequestException("connection reset"));
+
+ var exception = await Assert.ThrowsAsync(
+ () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ exception.IssueDescription.Should().Be(LookupFailedIssue,
+ because: "a network failure leaves the app's existence unknown");
+ exception.ErrorDetails.Should().Contain(d => d.Contains("connection reset", StringComparison.Ordinal),
+ because: "the underlying transport failure must be surfaced so the operator can act on it");
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenApplicationLookupTimesOut_ReportsLookupFailureNotCancellation()
+ {
+ // HttpClient surfaces its own timeout as TaskCanceledException with no cancellation
+ // requested; treating it as a cancel would abort the command instead of reporting a
+ // recoverable lookup failure.
+ _graphApiService.GraphGetWithResponseAsync(
+ Arg.Any(),
+ Arg.Is(p => p.Contains("displayName")),
+ Arg.Any(),
+ Arg.Any?>(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(_ =>
+ throw new TaskCanceledException("The request was canceled due to the configured HttpClient.Timeout"));
+
+ var exception = await Assert.ThrowsAsync(
+ () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId));
+
+ exception.IssueDescription.Should().Be(LookupFailedIssue,
+ because: "a transport timeout is a lookup failure, not a caller cancellation or a confirmed absence");
+ }
+
+ [Fact]
+ public async Task EnsureValidClientAppAsync_WhenPostProvisioningRefetchFails_ReportsLookupFailureNotMissingPermissions()
+ {
+ // The confirming re-read after permission provisioning uses the same lookup. A throttled
+ // re-read must not be reported as a permission gap the operator has to fix by hand.
+ SetupAppInfoGet(ValidClientAppId, requiredResourceAccess: "[]");
+ SetupPermissionResolution();
+
+ var appJson = BuildAppInfoJson(ValidClientAppId, "[]");
+ var attempts = 0;
+ _graphApiService.GraphGetWithResponseAsync(
+ Arg.Any(),
+ Arg.Is(p => p.Contains("displayName")),
+ Arg.Any(),
+ Arg.Any?>(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(_ => Task.FromResult(Interlocked.Increment(ref attempts) == 1
+ ? new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = JsonDocument.Parse(appJson) }
+ : new GraphApiService.GraphResponse { IsSuccess = false, StatusCode = 429, ReasonPhrase = "Too Many Requests" }));
+
+ // Permission provisioning succeeds so the flow reaches the confirming re-read.
+ _graphApiService.GraphPatchAsync(
+ Arg.Any(), Arg.Any(), Arg.Any