Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -51,6 +52,45 @@ public static ClientAppValidationException AppNotFound(string clientAppId, strin
});
}

/// <summary>
/// Creates an exception when the client app lookup could not complete. Distinct from
/// <see cref="AppNotFound"/>: absence is only proven by a successful Graph response with no
/// matching application, never by an authorization, HTTP, or network failure.
/// </summary>
public static ClientAppValidationException ApplicationLookupFailed(
string clientAppId,
string tenantId,
string reason,
int statusCode = 0)
{
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.");
}
Comment on lines +66 to +70

mitigationSteps.Add("Confirm you are signed in to the intended tenant with 'az login --tenant <tenantId>'.");
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<string>
{
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<string, string>
{
["clientAppId"] = clientAppId,
["tenantId"] = tenantId,
["statusCode"] = statusCode.ToString(CultureInfo.InvariantCulture)
});
}

/// <summary>
/// Creates exception for missing permissions.
/// </summary>
Expand Down
172 changes: 120 additions & 52 deletions src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -363,9 +363,9 @@ public virtual async Task<bool> ServicePrincipalExistsAsync(string tenantId, str
/// Executes a GET request to Microsoft Graph API.
/// Virtual to allow mocking in unit tests using Moq.
/// </summary>
public virtual async Task<JsonDocument?> GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable<string>? scopes = null)
public virtual async Task<JsonDocument?> GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable<string>? 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
{
Expand Down Expand Up @@ -553,9 +553,9 @@ public virtual async Task<GraphResponse> GraphPostWithResponseAsync(string tenan
/// Executes a PATCH request to Microsoft Graph API.
/// Virtual to allow mocking in unit tests using Moq.
/// </summary>
public virtual async Task<bool> GraphPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable<string>? scopes = null)
public virtual async Task<bool> GraphPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable<string>? 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ public enum GraphAuthenticationMode

/// <summary>
/// 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.
/// </summary>
Ambient = 1
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,8 @@ public override Task<bool> GraphPatchAsync(
string relativePath,
object payload,
CancellationToken ct = default,
IEnumerable<string>? scopes = null)
IEnumerable<string>? scopes = null,
GraphAuthenticationMode authenticationMode = GraphAuthenticationMode.ResolvedClientApp)
=> Task.FromException<bool>(new HttpRequestException("Network error during PATCH"));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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";

/// <summary>
/// Ambient identity can read the directory, while a token minted for the custom client app
/// is refused by Graph.
/// </summary>
private sealed class DualIdentityGraphHandler : HttpMessageHandler
{
public List<string> RequestsAsCustomApp { get; } = new();
public List<string> RequestsAsAmbient { get; } = new();

protected override Task<HttpResponseMessage> 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")
});
}
Comment on lines +44 to +60

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<IAuthenticationService>();
authService.GetAccessTokenAsync(Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<bool>(),
Arg.Any<string?>(), Arg.Any<IEnumerable<string>?>(), Arg.Any<bool>(), Arg.Any<string?>())
.Returns(Task.FromResult(AmbientToken));

var tokenProvider = Substitute.For<IMicrosoftGraphTokenProvider>();
tokenProvider.GetMgGraphAccessTokenAsync(
Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<bool>(),
Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>(), Arg.Any<bool>())
.Returns(Task.FromResult<string?>(CustomAppToken));

return new GraphApiService(
NullLogger<GraphApiService>.Instance,
Substitute.For<CommandExecutor>(Substitute.For<ILogger<CommandExecutor>>()),
authService, handler, tokenProvider,
loginHintResolver: () => Task.FromResult<string?>(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<ClientAppValidator>.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<ClientAppValidator>.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");
}
}
Loading
Loading