Skip to content

Run client app validation as the ambient operator identity - #491

Closed
Krishnadheeraj (DheerajPannala) wants to merge 1 commit into
mainfrom
kpannala-microsoft-fix-custom-app-lookup
Closed

Run client app validation as the ambient operator identity#491
Krishnadheeraj (DheerajPannala) wants to merge 1 commit into
mainfrom
kpannala-microsoft-fix-custom-app-lookup

Conversation

@DheerajPannala

@DheerajPannala Krishnadheeraj (DheerajPannala) commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

When setup requirements falls back to a tenant-owned custom client app, the resolved app ID is set on GraphApiService.CustomClientAppId. From that point on, every Microsoft Graph call in client app validation authenticated as the app being validated, and EnsureGraphHeadersAsync defaults that token request to User.Read.

Reading application and servicePrincipal metadata requires Application.Read.All, so Graph answered 403 Authorization_RequestDenied to all of them. Two distinct failures followed:

  1. The existence probe returned null on the 403, and the caller translated that into AppNotFound — telling operators their client app did not exist and directing them to re-create an app registration that was already present.
  2. Every subsequent read also returned 403. Permission resolution produced an empty map, so all seven required permissions were reported missing, the read-only pre-flight checks failed closed, and a repair PATCH was attempted and refused.

This reproduces in any tenant where the Agent 365 CLI application is not provisioned, so setup falls back to a tenant-owned app. The app is demonstrably present in that situation — the ambient bootstrap lookup already found it by display name on the same /v1.0/applications endpoint, and MSAL had acquired a token as that client.

Fix

Existence probe (GetClientAppInfoAsync):

  • The probe and its 401 force-refresh retry use GraphAuthenticationMode.Ambient. A token issued for the app under validation cannot prove that app's own absence, and requesting Application.Read.All from it would be circular when the app is missing or has not consented that scope.
  • null is reserved exclusively for a successful response with an empty value array — the only outcome that proves absence.
  • 403, 429, 5xx, status 0 / NoAuth, transport failures and malformed responses throw ClientAppValidationException.ApplicationLookupFailed, preserving the HTTP status and reason. A 403 can never be reported as AppNotFound.
  • TokenRevoked is raised only when the refreshed attempt is itself a 401.
  • Caller cancellation propagates; an HttpClient timeout is a lookup failure, not a Ctrl+C.

The rest of the validation flow: the 23 remaining tenant-directory reads and repairs (15 GETs, 6 PATCHes, 2 status-bearing GETs) now route through three small ambient helpers, so the whole flow runs as the ambient operator identity — the same identity that already resolves the app by display name. GraphGetAsync and GraphPatchAsync gained an authenticationMode parameter defaulting to the existing ResolvedClientApp, so no other caller changes behavior.

The wids / scp token-inspection paths deliberately keep using the custom-app token, since they exist to read claims that only that app's registration carries.

ClientAppRequirementCheck no longer appends "ensure the app exists" to every validation failure, which contradicted the new guidance.

Not changed

Untouched: LookupServicePrincipalByAppIdWithResponseAsync, EnsureValidFirstPartyClientAppAsync, GraphAuthenticationMode defaults, first-party token scope validation, service-principal creation, fallback SP propagation, and RequirementsSubcommand (which still sets the resolved client app ID for later operations).

Testing

Full CLI test project: 2034 passed, 12 skipped, 0 failed (2046 total, up from 2035). Release build: 0 warnings, 0 errors.

ClientAppValidatorAmbientIdentityTests exercises the failing scenario end to end with a dual-identity HTTP handler: the ambient token receives 200, a custom-app token receives 403 Authorization_RequestDenied. Against the pre-fix code the flow issued 1 ambient call and 8 forbidden ones and failed with "missing required API permissions"; it now issues 9 ambient calls, 0 forbidden, and validation succeeds. Both tests were verified to fail against the unfixed code with a clean rebuild.

Unit coverage added for the probe: ambient lookup when CustomClientAppId is set; 403 reported as an authorization failure with the status preserved and never as AppNotFound; 401 then 403 on retry reported as a lookup failure rather than revocation; 200-with-empty-result as the only path to AppNotFound; 429 and 5xx preserving status without offering the Application.Read.All step; status 0 preserving the reason phrase; five malformed-response shapes; ambient 401 force-refresh retry; transport failure and timeout; caller cancellation. Plus GraphApiService tests pinning that Ambient does not request a token for CustomClientAppId, paired with a ResolvedClientApp counter-test that it does.

Copilot AI lite review requested due to automatic review settings September 1, 2026 16:48
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes misleading client-app validation failures during setup requirements by distinguishing “confirmed absent” (empty successful Graph result) from “lookup inconclusive” (authorization/throttling/server/transport failures), and by ensuring the application existence probe uses the ambient bootstrap identity rather than authenticating as the app under validation.

Changes:

  • Updated ClientAppValidator.GetClientAppInfoAsync to use GraphAuthenticationMode.Ambient, retry only on 401, and throw a new “application lookup failed” exception for non-proving failures.
  • Added ClientAppValidationException.ApplicationLookupFailed(...) to preserve HTTP status/reason and provide targeted mitigation guidance (including 403 permission guidance).
  • Expanded unit tests to cover 403/401-retry behavior, malformed success payloads, status-0 failures, and ambient-vs-resolved token acquisition behavior; updated requirement-check messaging and added a changelog entry.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs Adds regression tests ensuring Ambient mode does not use the resolved custom-client token, with a counter-test for ResolvedClientApp mode.
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs Adds/updates coverage for “not found” vs “lookup failed” vs “token revoked”, including 403/429/5xx/status-0/malformed responses and cancellation vs timeout behavior.
src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs Removes “ensure the app exists” wording from failure details to avoid contradicting inconclusive-lookup scenarios.
src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs Switches the application lookup probe to Ambient auth, refines retry/exception behavior, and tightens response-shape validation.
src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs Introduces ApplicationLookupFailed exception factory with preserved status in context and targeted mitigations (notably for 403).
CHANGELOG.md Adds a consumer-facing fixed entry for the corrected “app not found” misdiagnosis during Graph lookup failures.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +270 to +272
// 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.
Copilot AI review requested due to automatic review settings September 1, 2026 23:31
@DheerajPannala Krishnadheeraj (DheerajPannala) changed the title Report inconclusive client app lookups instead of "app not found" Run client app validation as the ambient operator identity Sep 1, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The fix is well-scoped, aligns with the stated problem/mitigation, and is backed by strong regression and end-to-end test coverage (only minor follow-ups suggested).

Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines 1391 to +1394
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)
{
Comment on lines +1375 to 1389
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);
}
Copilot AI review requested due to automatic review settings September 1, 2026 23:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two newly added tests can currently pass under missing/invalid authentication headers or unsent requests, weakening the intended regression protection for the ambient-identity fix.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs:568

  • This test can pass even if GraphGetWithResponseAsync never sends a request (e.g., token acquisition fails and it returns the NoAuth failure), because it only asserts that the custom-app token provider was not used. Queue a success response and assert at least one request was made / the call succeeded so the test actually guards ambient-mode behavior end-to-end.
  • Files reviewed: 9/9 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +44 to +60
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")
});
}
@DheerajPannala
Krishnadheeraj (DheerajPannala) force-pushed the kpannala-microsoft-fix-custom-app-lookup branch from 0e6e483 to 1d42d9b Compare September 1, 2026 23:44
Once the bootstrap resolves a tenant-owned client app, every Microsoft Graph
call in client app validation authenticated as that app, whose token defaults
to User.Read. Reading application and servicePrincipal metadata needs
Application.Read.All, so Graph refused all of them with 403
Authorization_RequestDenied.

Two failures followed. The existence probe returned null on the 403 and the
caller reported the app as not found, directing operators to re-create an app
registration that was already present. Every later read failed the same way,
so permission resolution returned nothing and a fully configured app was
reported as missing all seven required permissions.

Route the tenant-directory reads and repairs through the ambient bootstrap
identity, the same one that already resolves the app by display name, and
reserve a null lookup result for a successful response with no match.
Authorization, throttling, server, transport and malformed-response outcomes
now report an inconclusive lookup that preserves the HTTP status, and token
revocation is reported only when a refreshed attempt is itself a 401.

GraphGetAsync and GraphPatchAsync gain an authentication-mode parameter that
defaults to the existing behavior, so no other caller changes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 1, 2026 23:57
@DheerajPannala
Krishnadheeraj (DheerajPannala) force-pushed the kpannala-microsoft-fix-custom-app-lookup branch from 1d42d9b to d0ccfb2 Compare September 1, 2026 23:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are a couple of correctness/message issues in the updated lookup/error paths that should be addressed before approval (see stored review comments).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +1423 to +1427
var displayName = app["displayName"] is JsonValue nameValue && nameValue.TryGetValue<string>(out var name)
? name
: string.Empty;

var app = apps[0]!.AsObject();
return new ClientAppInfo(
app["id"]?.GetValue<string>() ?? string.Empty,
app["displayName"]?.GetValue<string>() ?? string.Empty,
app["requiredResourceAccess"]?.AsArray());
return new ClientAppInfo(objectId, displayName, app["requiredResourceAccess"] as JsonArray);
Comment on lines +66 to +70
var mitigationSteps = new List<string>();
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.");
}
@rbrighenti

Copy link
Copy Markdown

Abandoning the changes here, in favor of the fix being combined with #478 where the issue is needed for different clouds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants