diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bcbe91a..5ed11e86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Support configurable scheduler token audiences and government defaults ([#806](https://github.com/microsoft/durabletask-dotnet/pull/806)) + ## v1.26.0 - Adding rewind to the sidecar by sophiatev ([#802](https://github.com/microsoft/durabletask-dotnet/pull/802)) - Prevent external-event loss after canceled waits in isolated worker by wangbill ([#801](https://github.com/microsoft/durabletask-dotnet/pull/801)) diff --git a/README.md b/README.md index 2fc375471..df785410f 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,104 @@ For runnable DTS emulator examples that demonstrate versioning, see the [WorkerV The [on-demand sandbox activities sample](samples/on-demand-sandbox/README.md) shows how to declare selected activities for Durable Task Scheduler (DTS)-managed on-demand sandbox execution and build the remote worker container image separately from the declarer app. +### Token audiences and Azure Government + +`DurableTaskSchedulerClientOptions.ResourceId` and `DurableTaskSchedulerWorkerOptions.ResourceId` +configure the **token audience URI**, not an Azure Resource Manager resource path. +The same setting is available as `ResourceId` in a scheduler connection string, for every +authentication type. A `UseDurableTaskScheduler` configuration callback can override the +connection-string value. + +| Configuration | Selected audience | +| --- | --- | +| Explicit nonempty `ResourceId` | The normalized explicit value | +| Missing, null, or empty `ResourceId`, with `REGION_NAME` starting with `usgov` or `usdod` (case-insensitive) | `https://durabletask.azure.us` | +| Otherwise | `https://durabletask.io` | + +The default is resolved per options instance and retained across token refreshes and channel +recreation. Region matching uses prefixes only: `chinaeast2`, `notusgov`, and `notusdod` use +the public default. The audience is **not inferred from the service endpoint**. + +Explicit values are normalized by trimming surrounding whitespace and trailing `/` characters, +removing one case-insensitive `/.default` suffix, and trimming trailing `/` characters again. +The SDK requests `/.default`. For example, +`https://durabletask.azure.us//.DEFAULT//` requests `https://durabletask.azure.us/.default`, +and `api://CustomAudience/resource/.DEFAULT/` requests +`api://CustomAudience/resource/.default`. Custom URI casing is preserved. +Whitespace-only values, `///`, `/.default`, and `/.DEFAULT///` throw an `ArgumentException` +instead of silently selecting a default. + +**Government-cloud configuration:** the government/DoD default now matches the audience +registered in that cloud, rather than the previous public-cloud `https://durabletask.io` +audience. Supported deployments use a scheduler and credentials in the same cloud; +this setting does not enable cross-cloud scheduler access. Public-cloud deployments retain +their existing default. An explicit `ResourceId = "https://durabletask.io"` on the client +and worker (or in their connection strings) still selects the public audience regardless +of `REGION_NAME`. + +#### Configure the credential authority separately + +Neither `ResourceId` nor `REGION_NAME` changes the endpoint or the credential's authority. +When supplying a `TokenCredential`, configure the authority on that credential. For example, +the following standalone client and worker configuration explicitly selects Azure Government: + +```csharp +using Azure.Identity; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; + +string endpoint = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_ENDPOINT") + ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_ENDPOINT is not set."); +string taskHub = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_TASK_HUB") + ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_TASK_HUB is not set."); + +DefaultAzureCredential credential = new(new DefaultAzureCredentialOptions +{ + AuthorityHost = AzureAuthorityHosts.AzureGovernment, +}); + +ServiceCollection services = new(); +services.AddDurableTaskClient(builder => + builder.UseDurableTaskScheduler(endpoint, taskHub, credential, + options => options.ResourceId = "https://durabletask.azure.us")); +services.AddDurableTaskWorker(builder => + builder.UseDurableTaskScheduler(endpoint, taskHub, credential, + options => options.ResourceId = "https://durabletask.azure.us")); +// Register your orchestrations and activities on the worker before starting the host. +``` + +For SDK-created credentials, the connection string accepts an independent `AuthorityHost`: + +```text +Endpoint=https://;TaskHub=;Authentication=DefaultAzure;ResourceId=https://durabletask.azure.us;AuthorityHost=https://login.microsoftonline.us/ +``` + +`AuthorityHost` must be an absolute HTTPS URI. It is forwarded for `DefaultAzure`, +`WorkloadIdentity`, `Environment`, `VisualStudio`, `VisualStudioCode`, and `InteractiveBrowser`. +Omitting it (or leaving it empty) preserves Azure Identity defaults, including +`AZURE_AUTHORITY_HOST` where applicable. It is not applied to `ManagedIdentity`, `AzureCLI`, +`AzurePowerShell`, or `None`. Managed identity uses the hosting environment's identity +endpoint. Developer-tool credentials, including those in `DefaultAzureCredential`, may +require separate tool cloud configuration (for example, `az cloud set --name AzureUSGovernment` +before signing in with Azure CLI). + +On-demand sandbox management reuses the configured client channel and audience. Sandbox +workers and their registration/reconnect streams share the worker channel and audience; +`UseSandboxWorker()` resolves the same region default. To override it, configure the +corresponding `DurableTaskSchedulerWorkerOptions` using the options system: + +```csharp +services.Configure( + options => options.ResourceId = "https://durabletask.azure.us"); +``` + +For a named worker, pass its name to `Configure`. Sandbox workers create a managed identity +credential, so an Entra authority override does not apply. Caller-supplied gRPC channels or +call invokers remain responsible for their own authentication. + ## Obtaining the Protobuf definitions This project utilizes protobuf definitions from [durabletask-protobuf](https://github.com/microsoft/durabletask-protobuf), which are copied (vendored) into this repository under the `src/Grpc` directory. See the corresponding [README.md](./src/Grpc/README.md) for more information about how to update the protobuf definitions. diff --git a/samples/on-demand-sandbox/README.md b/samples/on-demand-sandbox/README.md index 2b0eaa64a..8e294a14e 100644 --- a/samples/on-demand-sandbox/README.md +++ b/samples/on-demand-sandbox/README.md @@ -40,6 +40,16 @@ $env:DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID = "" For `Authentication=DefaultAzure`, sign in with Azure CLI or configure another supported Azure identity before running the main app. +For government-cloud deployments, configure the token audience independently of the endpoint +and credential authority. The main app connection string accepts +`ResourceId=https://durabletask.azure.us;AuthorityHost=https://login.microsoftonline.us/`. +The remote worker uses managed identity (its hosting environment's identity endpoint, not an +Entra authority override). Its audience defaults to `https://durabletask.azure.us` when +`REGION_NAME` starts with `usgov` or `usdod`, case-insensitively. An explicit +`DurableTaskSchedulerWorkerOptions.ResourceId` overrides that default and is shared by work-item +and registration transports. See [token audiences and Azure Government](../../README.md#token-audiences-and-azure-government) +for configuration examples and the government-region default migration. + The worker profile class declares the image, CPU, memory, max concurrency, and on-demand sandbox activity identities with `options.AddActivity(name, version)`. The main app and remote worker both use the `shared/SandboxActivities.cs` constants so the workerProfile and worker registration stay in sync. You can also set the scheduler connection string in `main-app/appsettings.json`: diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs index 138e37f1a..90ce76573 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ public static class SandboxActivitiesClientServiceCollectionExtensions /// /// Adds a DTS on-demand sandbox activity management client using the default Durable Task client configuration. /// + /// Reuses the client channel, including its credential and configured token audience. /// The service collection to configure. /// The original service collection, for call chaining. public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient(this IServiceCollection services) @@ -27,6 +28,7 @@ public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient( /// /// Adds a DTS on-demand sandbox activity management client using a named Durable Task client configuration. /// + /// Reuses the named client channel, including its credential and configured token audience. /// The service collection to configure. /// The Durable Task client name whose scheduler channel should be reused. /// The original service collection, for call chaining. diff --git a/src/Client/AzureManaged/DurableTaskSchedulerClientExtensions.cs b/src/Client/AzureManaged/DurableTaskSchedulerClientExtensions.cs index efe79748c..a88de06ee 100644 --- a/src/Client/AzureManaged/DurableTaskSchedulerClientExtensions.cs +++ b/src/Client/AzureManaged/DurableTaskSchedulerClientExtensions.cs @@ -65,6 +65,7 @@ public static void UseDurableTaskScheduler( options.EndpointAddress = connectionOptions.EndpointAddress; options.TaskHubName = connectionOptions.TaskHubName; options.Credential = connectionOptions.Credential; + options.CopyResourceIdFrom(connectionOptions); options.AllowInsecureCredentials = connectionOptions.AllowInsecureCredentials; }, configure); diff --git a/src/Client/AzureManaged/DurableTaskSchedulerClientOptions.cs b/src/Client/AzureManaged/DurableTaskSchedulerClientOptions.cs index 01b2268cd..eac29155d 100644 --- a/src/Client/AzureManaged/DurableTaskSchedulerClientOptions.cs +++ b/src/Client/AzureManaged/DurableTaskSchedulerClientOptions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using Azure.Core; using Azure.Identity; using Grpc.Core; @@ -16,6 +17,9 @@ namespace Microsoft.DurableTask; /// public class DurableTaskSchedulerClientOptions { + readonly string defaultResourceId = DurableTaskSchedulerResourceId.GetDefault(); + string? resourceId; + /// /// Gets or sets the endpoint address of the Durable Task Scheduler resource. /// Expected to be in the format "https://{scheduler-name}.{region}.durabletask.io". @@ -32,13 +36,27 @@ public class DurableTaskSchedulerClientOptions /// /// Gets or sets the credential used to authenticate with the Durable Task Scheduler task hub resource. /// + /// Configure the authority host on this credential, separately from . public TokenCredential? Credential { get; set; } /// - /// Gets or sets the resource ID of the Durable Task Scheduler resource. - /// The default value is https://durabletask.io. + /// Gets or sets the token audience URI, not an Azure Resource Manager resource path. /// - public string ResourceId { get; set; } = "https://durabletask.io"; + /// + /// Null or empty values use the default resolved when these options are created: + /// https://durabletask.azure.us when REGION_NAME starts with usgov or + /// usdod (case-insensitive), or https://durabletask.io otherwise. + /// Explicit values have surrounding whitespace, trailing slashes, and one existing + /// /.default suffix removed. Token requests append /.default to the result. + /// This does not change or the credential's authority host. + /// + /// The explicit value is empty after normalization. + [AllowNull] + public string ResourceId + { + get => this.resourceId ?? this.defaultResourceId; + set => this.resourceId = DurableTaskSchedulerResourceId.Normalize(value); + } /// /// Gets or sets a value indicating whether to allow insecure channel credentials. @@ -55,6 +73,12 @@ public class DurableTaskSchedulerClientOptions /// Creates a new instance of from a connection string. /// /// The connection string to parse. + /// + /// Supports an optional ResourceId token audience and an independent AuthorityHost + /// HTTPS URI for Azure Identity credentials that support authority configuration. Omitting + /// AuthorityHost preserves Azure Identity defaults, including AZURE_AUTHORITY_HOST. + /// Managed identity uses its hosting environment; developer tools may need separate cloud configuration. + /// /// A new instance of . public static DurableTaskSchedulerClientOptions FromConnectionString(string connectionString) { @@ -75,10 +99,17 @@ internal static DurableTaskSchedulerClientOptions FromConnectionString( EndpointAddress = connectionString.Endpoint, TaskHubName = connectionString.TaskHubName, Credential = credential, + ResourceId = connectionString.ResourceId, AllowInsecureCredentials = credential is null, }; } + /// + /// Copies an already normalized audience without stripping a second meaningful /.default segment. + /// + /// The options with the resolved audience. + internal void CopyResourceIdFrom(DurableTaskSchedulerClientOptions source) => this.resourceId = source.ResourceId; + /// /// Creates a gRPC channel for communicating with the Durable Task Scheduler service. /// @@ -173,11 +204,11 @@ this.Credential is not null switch (authType.ToLowerInvariant()) { case "defaultazure": - return new DefaultAzureCredential(); // CodeQL [SM05137] Use DefaultAzureCredential explicitly for local development and is decided by the user + return new DefaultAzureCredential(connectionString.CreateCredentialOptions()); // CodeQL [SM05137] Use DefaultAzureCredential explicitly for local development and is decided by the user case "managedidentity": return new ManagedIdentityCredential(connectionString.ClientId); case "workloadidentity": - WorkloadIdentityCredentialOptions opts = new WorkloadIdentityCredentialOptions(); + WorkloadIdentityCredentialOptions opts = connectionString.CreateCredentialOptions(); if (!string.IsNullOrEmpty(connectionString.ClientId)) { opts.ClientId = connectionString.ClientId; @@ -198,17 +229,17 @@ this.Credential is not null return new WorkloadIdentityCredential(opts); case "environment": - return new EnvironmentCredential(); + return new EnvironmentCredential(connectionString.CreateCredentialOptions()); case "azurecli": return new AzureCliCredential(); case "azurepowershell": return new AzurePowerShellCredential(); case "visualstudio": - return new VisualStudioCredential(); + return new VisualStudioCredential(connectionString.CreateCredentialOptions()); case "visualstudiocode": - return new VisualStudioCodeCredential(); + return new VisualStudioCodeCredential(connectionString.CreateCredentialOptions()); case "interactivebrowser": - return new InteractiveBrowserCredential(); + return new InteractiveBrowserCredential(connectionString.CreateCredentialOptions()); case "none": return null; default: diff --git a/src/Client/AzureManaged/RELEASENOTES.md b/src/Client/AzureManaged/RELEASENOTES.md index 5df7dac1c..6766f7b1d 100644 --- a/src/Client/AzureManaged/RELEASENOTES.md +++ b/src/Client/AzureManaged/RELEASENOTES.md @@ -1 +1,3 @@ +- Support normalized ResourceId token audiences in options and connection strings, with per-instance government/DoD defaults that select the audience registered in that cloud. Public-cloud defaults are unchanged; cross-cloud scheduler access is unsupported. +- Support independent AuthorityHost connection-string configuration for SDK-created credentials that accept an authority, preserving Azure Identity defaults when omitted. - Released first version Microsoft.DurableTask.Client.AzureManaged - 1.5.0-preview.1 \ No newline at end of file diff --git a/src/Shared/AzureManaged/DurableTaskSchedulerConnectionString.cs b/src/Shared/AzureManaged/DurableTaskSchedulerConnectionString.cs index 66368a551..a3e13aff1 100644 --- a/src/Shared/AzureManaged/DurableTaskSchedulerConnectionString.cs +++ b/src/Shared/AzureManaged/DurableTaskSchedulerConnectionString.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Data.Common; +using Azure.Identity; namespace Microsoft.DurableTask; @@ -59,8 +60,39 @@ public DurableTaskSchedulerConnectionString(string connectionString) /// public string TaskHubName => this.GetRequiredValue("TaskHub"); + /// + /// Gets the optional token audience URI. Normalization is performed by the scheduler options. + /// + public string? ResourceId => this.GetValue("ResourceId"); + string? AdditionallyAllowedTenantsStr => this.GetValue("AdditionallyAllowedTenants"); + /// + /// Creates credential options, forwarding an explicit authority only when supplied. + /// + /// The Azure Identity options type. + /// Options for a credential that supports authority configuration. + public TOptions CreateCredentialOptions() + where TOptions : TokenCredentialOptions, new() + { + TOptions options = new(); + string? authorityHost = this.GetValue("AuthorityHost"); + if (!string.IsNullOrEmpty(authorityHost)) + { + if (!Uri.TryCreate(authorityHost, UriKind.Absolute, out Uri? authority) + || authority.Scheme != Uri.UriSchemeHttps) + { + throw new ArgumentException( + "The connection string AuthorityHost must be an absolute HTTPS URI, such as https://login.microsoftonline.us/.", + "connectionString"); + } + + options.AuthorityHost = authority; + } + + return options; + } + string? GetValue(string name) => this.builder.TryGetValue(name, out object? value) ? value as string diff --git a/src/Shared/AzureManaged/DurableTaskSchedulerResourceId.cs b/src/Shared/AzureManaged/DurableTaskSchedulerResourceId.cs new file mode 100644 index 000000000..78fd743bb --- /dev/null +++ b/src/Shared/AzureManaged/DurableTaskSchedulerResourceId.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Resolves and normalizes token audiences independently of endpoints and credential authorities. +/// +static class DurableTaskSchedulerResourceId +{ + const string ScopeSuffix = "/.default"; + + /// + /// Gets the default audience for a new options instance. + /// + /// The government or public cloud audience. + public static string GetDefault() + { + string? region = Environment.GetEnvironmentVariable("REGION_NAME"); + return region is not null + && (region.StartsWith("usgov", StringComparison.OrdinalIgnoreCase) + || region.StartsWith("usdod", StringComparison.OrdinalIgnoreCase)) + ? "https://durabletask.azure.us" + : "https://durabletask.io"; + } + + /// + /// Normalizes an explicitly configured audience, or returns null for an omitted value. + /// + /// The token audience URI, not an ARM resource path. + /// The normalized audience, or null to use the options instance's default. + public static string? Normalize(string? resourceId) + { + if (string.IsNullOrEmpty(resourceId)) + { + return null; + } + + string normalized = resourceId.Trim().TrimEnd('/'); + if (normalized.EndsWith(ScopeSuffix, StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[..^ScopeSuffix.Length].TrimEnd('/'); + } + + if (normalized.Length == 0) + { + throw new ArgumentException( + "ResourceId must contain a token audience URI after removing whitespace, trailing slashes, and a /.default suffix.", + nameof(resourceId)); + } + + return normalized; + } +} diff --git a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs index 2f18963a6..c3b76ea05 100644 --- a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -34,6 +34,11 @@ public static class DurableTaskSchedulerSandboxWorkerExtensions /// on-demand sandbox activities. Use this on a dedicated worker binary that runs inside sandbox infrastructure. /// Runtime configuration is read from environment variables injected by DTS. /// + /// + /// The worker and sandbox registration share . + /// Configure the named scheduler options to override the region-based token audience default. + /// Managed identity uses the hosting environment's identity endpoint, not an Entra authority override. + /// /// The Durable Task worker builder to configure. /// The original builder, for call chaining. public static IDurableTaskWorkerBuilder UseSandboxWorker(this IDurableTaskWorkerBuilder builder) diff --git a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs index 2b832a54b..9376979d3 100644 --- a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs +++ b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs @@ -65,6 +65,7 @@ public static void UseDurableTaskScheduler( options.EndpointAddress = connectionOptions.EndpointAddress; options.TaskHubName = connectionOptions.TaskHubName; options.Credential = connectionOptions.Credential; + options.CopyResourceIdFrom(connectionOptions); options.AllowInsecureCredentials = connectionOptions.AllowInsecureCredentials; }, configure); diff --git a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerOptions.cs b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerOptions.cs index de2b75d4b..3285624ce 100644 --- a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerOptions.cs +++ b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerOptions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using Azure.Core; using Azure.Identity; using Grpc.Core; @@ -16,6 +17,9 @@ namespace Microsoft.DurableTask; /// public class DurableTaskSchedulerWorkerOptions { + readonly string defaultResourceId = DurableTaskSchedulerResourceId.GetDefault(); + string? resourceId; + /// /// Gets or sets the endpoint address of the Durable Task Scheduler resource. /// Expected to be in the format "https://{scheduler-name}.{region}.durabletask.io". @@ -32,13 +36,27 @@ public class DurableTaskSchedulerWorkerOptions /// /// Gets or sets the credential used to authenticate with the Durable Task Scheduler task hub resource. /// + /// Configure the authority host on this credential, separately from . public TokenCredential? Credential { get; set; } /// - /// Gets or sets the resource ID of the Durable Task Scheduler resource. - /// The default value is https://durabletask.io. + /// Gets or sets the token audience URI, not an Azure Resource Manager resource path. /// - public string ResourceId { get; set; } = "https://durabletask.io"; + /// + /// Null or empty values use the default resolved when these options are created: + /// https://durabletask.azure.us when REGION_NAME starts with usgov or + /// usdod (case-insensitive), or https://durabletask.io otherwise. + /// Explicit values have surrounding whitespace, trailing slashes, and one existing + /// /.default suffix removed. Token requests append /.default to the result. + /// This does not change or the credential's authority host. + /// + /// The explicit value is empty after normalization. + [AllowNull] + public string ResourceId + { + get => this.resourceId ?? this.defaultResourceId; + set => this.resourceId = DurableTaskSchedulerResourceId.Normalize(value); + } /// /// Gets or sets the worker ID used to identify the worker instance. @@ -56,6 +74,12 @@ public class DurableTaskSchedulerWorkerOptions /// Creates a new instance of from a connection string. /// /// The connection string to parse. + /// + /// Supports an optional ResourceId token audience and an independent AuthorityHost + /// HTTPS URI for Azure Identity credentials that support authority configuration. Omitting + /// AuthorityHost preserves Azure Identity defaults, including AZURE_AUTHORITY_HOST. + /// Managed identity uses its hosting environment; developer tools may need separate cloud configuration. + /// /// A new instance of . public static DurableTaskSchedulerWorkerOptions FromConnectionString(string connectionString) { @@ -76,10 +100,17 @@ internal static DurableTaskSchedulerWorkerOptions FromConnectionString( EndpointAddress = connectionString.Endpoint, TaskHubName = connectionString.TaskHubName, Credential = credential, + ResourceId = connectionString.ResourceId, AllowInsecureCredentials = credential is null, }; } + /// + /// Copies an already normalized audience without stripping a second meaningful /.default segment. + /// + /// The options with the resolved audience. + internal void CopyResourceIdFrom(DurableTaskSchedulerWorkerOptions source) => this.resourceId = source.ResourceId; + /// /// Creates a gRPC channel for communicating with the Durable Task Scheduler service. /// @@ -135,11 +166,11 @@ this.Credential is not null switch (authType.ToLowerInvariant()) { case "defaultazure": - return new DefaultAzureCredential(); // CodeQL [SM05137] Use DefaultAzureCredential explicitly for local development and is decided by the user + return new DefaultAzureCredential(connectionString.CreateCredentialOptions()); // CodeQL [SM05137] Use DefaultAzureCredential explicitly for local development and is decided by the user case "managedidentity": return new ManagedIdentityCredential(connectionString.ClientId); case "workloadidentity": - WorkloadIdentityCredentialOptions opts = new WorkloadIdentityCredentialOptions(); + WorkloadIdentityCredentialOptions opts = connectionString.CreateCredentialOptions(); if (!string.IsNullOrEmpty(connectionString.ClientId)) { opts.ClientId = connectionString.ClientId; @@ -160,17 +191,17 @@ this.Credential is not null return new WorkloadIdentityCredential(opts); case "environment": - return new EnvironmentCredential(); + return new EnvironmentCredential(connectionString.CreateCredentialOptions()); case "azurecli": return new AzureCliCredential(); case "azurepowershell": return new AzurePowerShellCredential(); case "visualstudio": - return new VisualStudioCredential(); + return new VisualStudioCredential(connectionString.CreateCredentialOptions()); case "visualstudiocode": - return new VisualStudioCodeCredential(); + return new VisualStudioCodeCredential(connectionString.CreateCredentialOptions()); case "interactivebrowser": - return new InteractiveBrowserCredential(); + return new InteractiveBrowserCredential(connectionString.CreateCredentialOptions()); case "none": return null; default: diff --git a/src/Worker/AzureManaged/RELEASENOTES.md b/src/Worker/AzureManaged/RELEASENOTES.md index a1c22aca1..ae020fe75 100644 --- a/src/Worker/AzureManaged/RELEASENOTES.md +++ b/src/Worker/AzureManaged/RELEASENOTES.md @@ -1 +1,3 @@ +- Support normalized ResourceId token audiences in options and connection strings, with per-instance government/DoD defaults retained for worker and sandbox registration reconnects. These defaults select the audience registered in that cloud. Public-cloud defaults are unchanged; cross-cloud scheduler access is unsupported. +- Support independent AuthorityHost connection-string configuration for SDK-created credentials that accept an authority, preserving Azure Identity defaults when omitted. - Released first version Microsoft.DurableTask.Worker.AzureManaged - 1.5.0-preview.1 \ No newline at end of file diff --git a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj index b9b8cd70b..3c74c6322 100644 --- a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj +++ b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientExtensionsTests.cs b/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientExtensionsTests.cs index 77c6df66c..7f53b4d88 100644 --- a/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientExtensionsTests.cs +++ b/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientExtensionsTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using Grpc.Core; using Grpc.Net.Client; +using Microsoft.DurableTask.AzureManaged.Tests; using Microsoft.DurableTask.Client.Grpc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -14,6 +15,7 @@ namespace Microsoft.DurableTask.Client.AzureManaged.Tests; +[Collection("Scheduler authentication environment")] public class DurableTaskSchedulerClientExtensionsTests { const string ValidEndpoint = "myaccount.westus3.durabletask.io"; @@ -67,10 +69,15 @@ public void UseDurableTaskScheduler_WithConnectionString_ShouldConfigureCorrectl clientOptions.Credential.Should().BeOfType(); } - [Fact] - public void UseDurableTaskScheduler_WithLocalhostConnectionString_ShouldConfigureCorrectly() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public void UseDurableTaskScheduler_WithLocalhostConnectionString_ShouldConfigureCorrectly(string? region, string expectedResourceId) { // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(b => b.Services).Returns(services); @@ -89,7 +96,7 @@ public void UseDurableTaskScheduler_WithLocalhostConnectionString_ShouldConfigur workerOptions.EndpointAddress.Should().Be("http://localhost"); workerOptions.TaskHubName.Should().Be(ValidTaskHub); workerOptions.Credential.Should().BeNull(); - workerOptions.ResourceId.Should().Be("https://durabletask.io"); + workerOptions.ResourceId.Should().Be(expectedResourceId); workerOptions.AllowInsecureCredentials.Should().BeTrue(); } @@ -170,10 +177,15 @@ public void UseDurableTaskScheduler_WithNullOrEmptyConnectionString_ShouldThrowA action.Should().Throw(); } - [Fact] - public void UseDurableTaskScheduler_WithNamedOptions_ShouldConfigureCorrectly() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public void UseDurableTaskScheduler_WithNamedOptions_ShouldConfigureCorrectly(string? region, string expectedResourceId) { // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); ServiceCollection services = new ServiceCollection(); Mock mockBuilder = new Mock(); mockBuilder.Setup(b => b.Services).Returns(services); @@ -192,7 +204,7 @@ public void UseDurableTaskScheduler_WithNamedOptions_ShouldConfigureCorrectly() options.EndpointAddress.Should().Be(ValidEndpoint); // The https:// prefix is added by CreateChannel, not in the extension method options.TaskHubName.Should().Be(ValidTaskHub); options.Credential.Should().BeOfType(); - options.ResourceId.Should().Be("https://durabletask.io"); + options.ResourceId.Should().Be(expectedResourceId); options.AllowInsecureCredentials.Should().BeFalse(); } diff --git a/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientOptionsTests.cs b/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientOptionsTests.cs index 05b8bd67e..0619d60cd 100644 --- a/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientOptionsTests.cs +++ b/test/Client/AzureManaged.Tests/DurableTaskSchedulerClientOptionsTests.cs @@ -3,10 +3,12 @@ using Azure.Identity; using FluentAssertions; +using Microsoft.DurableTask.AzureManaged.Tests; using Xunit; namespace Microsoft.DurableTask.Shared.AzureManaged.Tests; +[Collection("Scheduler authentication environment")] public class DurableTaskSchedulerClientOptionsTests { const string ValidEndpoint = "myaccount.westus3.durabletask.io"; @@ -189,14 +191,21 @@ public void FromConnectionString_WithNone_ShouldCreateInstanceWithNullCredential options.Credential.Should().BeNull(); } - [Fact] - public void DefaultProperties_ShouldHaveExpectedValues() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public void DefaultProperties_ShouldHaveExpectedValues(string? region, string expectedResourceId) { - // Arrange & Act + // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); + + // Act DurableTaskSchedulerClientOptions options = new DurableTaskSchedulerClientOptions(); // Assert - options.ResourceId.Should().Be("https://durabletask.io"); + options.ResourceId.Should().Be(expectedResourceId); options.AllowInsecureCredentials.Should().BeFalse(); } diff --git a/test/Client/AzureManaged.Tests/SandboxAuthenticationTests.cs b/test/Client/AzureManaged.Tests/SandboxAuthenticationTests.cs new file mode 100644 index 000000000..d0f736cf4 --- /dev/null +++ b/test/Client/AzureManaged.Tests/SandboxAuthenticationTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.DurableTask.AzureManaged.Tests; +using Microsoft.DurableTask.Client.Grpc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.DurableTask.Client.AzureManaged.Tests; + +[Collection("Scheduler authentication environment")] +public class SandboxAuthenticationTests(SchedulerAuthenticationServer server) : IClassFixture +{ + [Theory] + [InlineData(null, "https://durabletask.azure.us/.default")] + [InlineData("api://Custom/.default/.DEFAULT/", "api://Custom/.default/.default")] + public async Task Management_UsesSchedulerAudienceAndTokenCacheAsync(string? resourceId, string expectedScope) + { + // Arrange + using SchedulerEnvironmentVariable region = new("REGION_NAME", "UsGovVirginia"); + RecordingSchedulerCredential credential = new(expireFirstToken: true); + ServiceCollection services = new(); + Mock builder = new(); + builder.SetupGet(b => b.Services).Returns(services); + builder.SetupGet(b => b.Name).Returns("sandbox"); + builder.Object.UseDurableTaskScheduler( + $"Endpoint={server.Endpoint};TaskHub=testhub;Authentication=None;ResourceId=\"{resourceId}\"", + options => options.Credential = credential); + services.AddDurableTaskSchedulerSandboxActivitiesClient("sandbox"); + await using ServiceProvider provider = services.BuildServiceProvider(); + SandboxActivitiesClient client = provider.GetRequiredService(); + GrpcChannel channel = Assert.IsType( + provider.GetRequiredService>().Get("sandbox").Channel); + Assert.Empty(credential.Scopes); + server.ManagementHeaders.Clear(); + + // Act + await server.CallAsync(channel); + Environment.SetEnvironmentVariable("REGION_NAME", "westus2"); + await client.EnableSandboxActivitiesAsync(); + await client.RemoveSandboxWorkerProfileAsync("profile"); + await server.CallAsync(channel); + + // Assert + Assert.Equal([expectedScope, expectedScope], credential.Scopes); + Assert.True(server.ManagementHeaders.Count >= 2, "Both profile declaration and removal must use the authenticated transport."); + Assert.All(server.ManagementHeaders, headers => + { + Assert.Equal("testhub", Assert.Single(headers, header => header.Key == "taskhub").Value); + Assert.Equal("Bearer recorded-token", headers.GetValue("authorization")); + }); + } + + [Fact] + public async Task Management_CallerSuppliedChannelRemainsResponsibleForAuthenticationAsync() + { + // Arrange + RecordingSchedulerCredential unusedCredential = new(); + ServiceCollection services = new(); + services.Configure(options => + { + options.TaskHubName = "testhub"; + options.ResourceId = "https://durabletask.azure.us"; + options.Credential = unusedCredential; + }); + using GrpcChannel suppliedChannel = GrpcChannel.ForAddress(server.Endpoint); + services.Configure(options => options.CallInvoker = suppliedChannel.CreateCallInvoker()); + services.AddDurableTaskSchedulerSandboxActivitiesClient(); + await using ServiceProvider provider = services.BuildServiceProvider(); + server.ManagementHeaders.Clear(); + + // Act + await provider.GetRequiredService().RemoveSandboxWorkerProfileAsync("profile"); + + // Assert + Assert.Empty(unusedCredential.Scopes); + Metadata headers = Assert.Single(server.ManagementHeaders); + Assert.Null(headers.GetValue("authorization")); + Assert.Equal("testhub", Assert.Single(headers, header => header.Key == "taskhub").Value); + } +} diff --git a/test/Shared/AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs b/test/Shared/AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs index df2e80b5e..81d88ef8b 100644 --- a/test/Shared/AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs +++ b/test/Shared/AzureManaged.Tests/DurableTaskSchedulerConnectionStringTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Identity; using FluentAssertions; using Xunit; @@ -13,6 +14,61 @@ public class DurableTaskSchedulerConnectionStringTests const string ValidClientId = "00000000-0000-0000-0000-000000000000"; const string ValidTenantId = "11111111-1111-1111-1111-111111111111"; + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("https://login.microsoftonline.us/")] + public void CreateCredentialOptions_PreservesEnvironmentDefaultUnlessExplicit(string? authorityHost) + { + // Arrange + string? originalAuthority = Environment.GetEnvironmentVariable("AZURE_AUTHORITY_HOST"); + Environment.SetEnvironmentVariable("AZURE_AUTHORITY_HOST", "https://login.partner.microsoftonline.cn/"); + try + { + DurableTaskSchedulerConnectionString connectionString = new($"AuthorityHost=\"{authorityHost}\""); + Uri expected = string.IsNullOrEmpty(authorityHost) + ? new DefaultAzureCredentialOptions().AuthorityHost + : new Uri(authorityHost); + + // Act + TokenCredentialOptions[] options = + [ + connectionString.CreateCredentialOptions(), + connectionString.CreateCredentialOptions(), + connectionString.CreateCredentialOptions(), + connectionString.CreateCredentialOptions(), + connectionString.CreateCredentialOptions(), + connectionString.CreateCredentialOptions(), + ]; + + // Assert + Assert.All(options, option => Assert.Equal(expected, option.AuthorityHost)); + Assert.Equal("https://login.partner.microsoftonline.cn/", new DefaultAzureCredentialOptions().AuthorityHost.AbsoluteUri); + } + finally + { + Environment.SetEnvironmentVariable("AZURE_AUTHORITY_HOST", originalAuthority); + } + } + + [Theory] + [InlineData("not-a-uri")] + [InlineData("http://login.example.com")] + [InlineData(" \t ")] + public void CreateCredentialOptions_RejectsInvalidAuthority(string authorityHost) + { + // Arrange + DurableTaskSchedulerConnectionString connectionString = new($"AuthorityHost=\"{authorityHost}\""); + + // Act + ArgumentException exception = Assert.Throws( + () => connectionString.CreateCredentialOptions()); + + // Assert + Assert.Equal("connectionString", exception.ParamName); + Assert.Contains("AuthorityHost", exception.Message); + } + [Fact] public void Constructor_WithValidConnectionString_ShouldParseCorrectly() { diff --git a/test/Shared/AzureManaged/SchedulerAuthenticationTests.cs b/test/Shared/AzureManaged/SchedulerAuthenticationTests.cs new file mode 100644 index 000000000..0fb2c3885 --- /dev/null +++ b/test/Shared/AzureManaged/SchedulerAuthenticationTests.cs @@ -0,0 +1,442 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Reflection; +using Azure.Core; +using Azure.Identity; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; +#if SCHEDULER_WORKER +using Microsoft.DurableTask.Worker.AzureManaged; +using SchedulerBuilder = Microsoft.DurableTask.Worker.IDurableTaskWorkerBuilder; +using SchedulerOptions = Microsoft.DurableTask.DurableTaskSchedulerWorkerOptions; +using GrpcOptions = Microsoft.DurableTask.Worker.Grpc.GrpcDurableTaskWorkerOptions; +#else +using Microsoft.DurableTask.Client.AzureManaged; +using SchedulerBuilder = Microsoft.DurableTask.Client.IDurableTaskClientBuilder; +using SchedulerOptions = Microsoft.DurableTask.DurableTaskSchedulerClientOptions; +using GrpcOptions = Microsoft.DurableTask.Client.Grpc.GrpcDurableTaskClientOptions; +#endif + +namespace Microsoft.DurableTask.AzureManaged.Tests; + +[CollectionDefinition("Scheduler authentication environment", DisableParallelization = true)] +public class SchedulerAuthenticationEnvironmentCollection; + +[Collection("Scheduler authentication environment")] +public class SchedulerAuthenticationTests(SchedulerAuthenticationServer server) : IClassFixture +{ + public static IEnumerable ResourceIds() + { + (string? Region, string? Resource, string Expected)[] cases = + [ + (null, null, "https://durabletask.io"), + ("", null, "https://durabletask.io"), + ("westus2", "", "https://durabletask.io"), + ("chinaeast2", null, "https://durabletask.io"), + ("notusgov", null, "https://durabletask.io"), + ("notusdod", null, "https://durabletask.io"), + ("westusgov", null, "https://durabletask.io"), + (" usgovvirginia", null, "https://durabletask.io"), + ("usgovvirginia", null, "https://durabletask.azure.us"), + ("USGOVARIZONA", "", "https://durabletask.azure.us"), + ("UsGovTexas", null, "https://durabletask.azure.us"), + ("usdodcentral", null, "https://durabletask.azure.us"), + ("USDODEAST", "", "https://durabletask.azure.us"), + ("UsDodCentral", null, "https://durabletask.azure.us"), + ("usgov", null, "https://durabletask.azure.us"), + ("usdod", null, "https://durabletask.azure.us"), + ("usgovvirginia", "https://durabletask.io", "https://durabletask.io"), + ("usdodcentral", "https://durabletask.io", "https://durabletask.io"), + ("westus2", "https://durabletask.azure.us", "https://durabletask.azure.us"), + (null, "https://durabletask.azure.us/", "https://durabletask.azure.us"), + (null, "https://durabletask.azure.us//.DEFAULT//", "https://durabletask.azure.us"), + (null, " \thttps://durabletask.azure.us/.default/ \t", "https://durabletask.azure.us"), + ("usgovvirginia", "api://CustomAudience/resource/.DEFAULT/", "api://CustomAudience/resource"), + ("westus2", "api://custom/.default/.default", "api://custom/.default"), + ]; + + foreach (var item in cases) + { + foreach (bool fromConnectionString in new[] { false, true }) + { + yield return [item.Region, item.Resource, item.Expected, fromConnectionString]; + } + } + } + + [Theory] + [MemberData(nameof(ResourceIds))] + public async Task ResourceId_RequestsNormalizedScopeAndPreservesItAcrossRefreshAsync( + string? region, string? resourceId, string expectedResource, bool fromConnectionString) + { + // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); + RecordingSchedulerCredential credential = new(expireFirstToken: true); + SchedulerOptions options = fromConnectionString + ? SchedulerOptions.FromConnectionString(this.ConnectionString(resourceId)) + : new SchedulerOptions { EndpointAddress = server.Endpoint, TaskHubName = "testhub", ResourceId = resourceId }; + options.Credential = credential; + options.AllowInsecureCredentials = true; + using GrpcChannel channel = options.CreateChannel(); + Assert.Empty(credential.Scopes); + + // Act + Environment.SetEnvironmentVariable("REGION_NAME", "changed-after-channel-creation"); + await server.CallAsync(channel); + await server.CallAsync(channel); + await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => server.CallAsync(channel))); + + // Assert + Assert.Equal(expectedResource, options.ResourceId); + Assert.Equal([expectedResource + "/.default", expectedResource + "/.default"], credential.Scopes); + Assert.Equal(server.Endpoint, options.EndpointAddress); + Assert.Same(credential, options.Credential); + } + + [Theory] + [InlineData(" \t ")] + [InlineData("///")] + [InlineData("/.default")] + [InlineData("/.DEFAULT///")] + public void ResourceId_RejectsEmptyAfterNormalizationWithoutCredentials(string resourceId) + { + // Arrange + SchedulerOptions options = new(); + + // Act + ArgumentException direct = Assert.Throws(() => options.ResourceId = resourceId); + ArgumentException connection = Assert.Throws( + () => SchedulerOptions.FromConnectionString(this.ConnectionString(resourceId))); + + // Assert + Assert.Contains("ResourceId", direct.Message); + Assert.Contains("token audience URI", direct.Message); + Assert.Contains("ResourceId", connection.Message); + Assert.Null(options.Credential); + } + + [Fact] + public async Task Defaults_AreResolvedPerOptionsInstanceAndNotFromEndpointAsync() + { + // Arrange + using SchedulerEnvironmentVariable region = new("REGION_NAME", "usgovvirginia"); + SchedulerOptions government = new() { EndpointAddress = server.Endpoint, TaskHubName = "testhub" }; + SchedulerOptions governmentConnection = SchedulerOptions.FromConnectionString( + $"Endpoint={server.Endpoint};TaskHub=testhub;Authentication=None"); + Environment.SetEnvironmentVariable("REGION_NAME", "westus2"); + SchedulerOptions publicOptions = new() { EndpointAddress = server.Endpoint, TaskHubName = "testhub" }; + SchedulerOptions governmentEndpoint = new() + { + EndpointAddress = "https://example.usgovvirginia.durabletask.azure.us", + }; + Environment.SetEnvironmentVariable("REGION_NAME", "usdodcentral"); + publicOptions.ResourceId = null; + government.ResourceId = ""; + RecordingSchedulerCredential credential = new(); + + // Act + foreach (SchedulerOptions options in new[] { government, governmentConnection, publicOptions }) + { + options.Credential = credential; + options.AllowInsecureCredentials = true; + using GrpcChannel channel = options.CreateChannel(); + await server.CallAsync(channel); + } + + // Assert + Assert.Equal( + ["https://durabletask.azure.us/.default", "https://durabletask.azure.us/.default", "https://durabletask.io/.default"], + credential.Scopes); + Assert.Equal("https://durabletask.io", governmentEndpoint.ResourceId); + Assert.Equal("https://example.usgovvirginia.durabletask.azure.us", governmentEndpoint.EndpointAddress); + } + + [Fact] + public async Task ResourceId_RefreshOnPreservesSelectedScopeAsync() + { + // Arrange + using SchedulerEnvironmentVariable region = new("REGION_NAME", "usgovvirginia"); + RecordingSchedulerCredential credential = new(refreshFirstToken: true); + SchedulerOptions options = new() + { + EndpointAddress = server.Endpoint, + TaskHubName = "testhub", + Credential = credential, + AllowInsecureCredentials = true, + }; + using GrpcChannel channel = options.CreateChannel(); + + // Act + await server.CallAsync(channel); + Environment.SetEnvironmentVariable("REGION_NAME", "westus2"); + await server.CallAsync(channel); + + // Assert + Assert.Equal(Enumerable.Repeat("https://durabletask.azure.us/.default", 2), credential.Scopes); + } + + [Theory] + [CombinatorialData] + public async Task RecordingCredential_FirstTokenIsUniqueUnderConcurrencyAsync(bool expireFirstToken, bool refreshFirstToken) + { + // Arrange + RecordingSchedulerCredential credential = new(expireFirstToken, refreshFirstToken); + TokenRequestContext context = new(["https://durabletask.azure.us/.default"]); + TaskCompletionSource start = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task[] requests = Enumerable.Range(0, 64).Select(async _ => + { + await start.Task; + return await credential.GetTokenAsync(context, CancellationToken.None); + }).ToArray(); + + // Act + start.SetResult(); + AccessToken[] tokens = await Task.WhenAll(requests).WaitAsync(TimeSpan.FromSeconds(10)); + + // Assert + DateTimeOffset now = DateTimeOffset.UtcNow; + Assert.Equal(expireFirstToken ? 1 : 0, tokens.Count(token => token.ExpiresOn < now)); + Assert.Equal(refreshFirstToken ? 1 : 0, tokens.Count(token => token.RefreshOn < now)); + if (expireFirstToken && refreshFirstToken) + { + Assert.Single(tokens, token => token.ExpiresOn < now && token.RefreshOn < now); + } + + Assert.Equal(Enumerable.Repeat("https://durabletask.azure.us/.default", requests.Length), credential.Scopes); + } + + [Theory] + [CombinatorialData] + public async Task Builders_PreserveAudienceAcrossOptionsCopyAndChannelRecreationAsync( + [CombinatorialValues("options", "endpoint", "connectionString")] string path, + [CombinatorialValues(null, "", "https://durabletask.io", "api://Custom/.default/.DEFAULT/")] string? resourceId, + bool callbackOverride) + { + // Arrange + using SchedulerEnvironmentVariable region = new("REGION_NAME", "UsDodCentral"); + RecordingSchedulerCredential credential = new(expireFirstToken: true); + ServiceCollection services = new(); + Mock builder = new(); + builder.SetupGet(b => b.Services).Returns(services); + builder.SetupGet(b => b.Name).Returns("named"); + Action configure = options => + { + options.Credential = credential; + options.AllowInsecureCredentials = true; + if (callbackOverride) + { + options.ResourceId = "api://Override/Resource/.DEFAULT/"; + } + }; + switch (path) + { + case "options": + builder.Object.UseDurableTaskScheduler(options => + { + options.EndpointAddress = server.Endpoint; + options.TaskHubName = "testhub"; + options.ResourceId = resourceId; + configure(options); + }); + break; + case "endpoint": + builder.Object.UseDurableTaskScheduler(server.Endpoint, "testhub", credential, options => + { + options.ResourceId = resourceId; + configure(options); + }); + break; + default: + builder.Object.UseDurableTaskScheduler(this.ConnectionString(resourceId), configure); + // Connection-string options are captured at registration time, not DI resolution. + Environment.SetEnvironmentVariable("REGION_NAME", "westus2"); + break; + } + + await using ServiceProvider provider = services.BuildServiceProvider(); + GrpcOptions grpcOptions = provider.GetRequiredService>().Get("named"); + GrpcChannel original = Assert.IsType(grpcOptions.Channel); + Assert.Empty(credential.Scopes); + string expected = callbackOverride ? "api://Override/Resource" + : string.IsNullOrEmpty(resourceId) ? "https://durabletask.azure.us" + : resourceId == "https://durabletask.io" ? resourceId : "api://Custom/.default"; + + // Act + await server.CallAsync(original); + Environment.SetEnvironmentVariable("REGION_NAME", "westus2"); + await server.CallAsync(original); + Func> recreate = GetChannelRecreator(grpcOptions); + GrpcChannel replacement = await recreate(original, CancellationToken.None); + await server.CallAsync(replacement); + + // Assert + Assert.NotSame(original, replacement); + Assert.Equal(Enumerable.Repeat(expected + "/.default", 3), credential.Scopes); + Assert.Equal(expected, provider.GetRequiredService>().Get("named").ResourceId); + } + + [Theory] + [InlineData("DefaultAzure")] + [InlineData("ManagedIdentity")] + [InlineData("WorkloadIdentity")] + [InlineData("Environment")] + [InlineData("AzureCLI")] + [InlineData("AzurePowerShell")] + [InlineData("VisualStudio")] + [InlineData("VisualStudioCode")] + [InlineData("InteractiveBrowser")] + [InlineData("None")] + public async Task ConnectionString_ResourceIdAppliesToEveryAuthenticationTypeAsync(string authentication) + { + // Arrange + using SchedulerEnvironmentVariable tokenFile = new("AZURE_FEDERATED_TOKEN_FILE", "unused-test-token-file"); + SchedulerOptions options = SchedulerOptions.FromConnectionString( + $"Endpoint={server.Endpoint};TaskHub=testhub;Authentication={authentication};" + + "ClientID=11111111-1111-1111-1111-111111111111;TenantId=22222222-2222-2222-2222-222222222222;" + + "resourceid=\" api://CustomAudience/.DEFAULT/ \";AuthorityHost=https://login.microsoftonline.us/"); + RecordingSchedulerCredential credential = new(); + bool anonymous = options.Credential is null; + options.Credential = credential; + options.AllowInsecureCredentials = true; + + // Act + using GrpcChannel channel = options.CreateChannel(); + await server.CallAsync(channel); + + // Assert + Assert.Equal(authentication == "None", anonymous); + Assert.Equal(["api://CustomAudience/.default"], credential.Scopes); + } + + [Theory] + [InlineData("https://durabletask.azure.us")] + [InlineData(null)] + public async Task AnonymousChannel_DoesNotAttachAuthorizationAsync(string? resourceId) + { + // Arrange + using SchedulerEnvironmentVariable region = new("REGION_NAME", "usgovvirginia"); + SchedulerOptions options = SchedulerOptions.FromConnectionString(this.ConnectionString(resourceId)); + using GrpcChannel channel = options.CreateChannel(); + + // Act + byte[] response = await server.CallAsync(channel); + + // Assert + Assert.Empty(response); + Assert.Null(options.Credential); + } + + static Func> GetChannelRecreator(GrpcOptions options) + { + object internalOptions = typeof(GrpcOptions) + .GetProperty("Internal", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(options)!; + return Assert.IsType>>( + internalOptions.GetType().GetProperty("ChannelRecreator")!.GetValue(internalOptions)); + } + + string ConnectionString(string? resourceId) => + $"Endpoint={server.Endpoint};TaskHub=testhub;Authentication=None;resourceid=\"{resourceId}\""; +} + +public sealed class RecordingSchedulerCredential(bool expireFirstToken = false, bool refreshFirstToken = false) : TokenCredential +{ + readonly ConcurrentQueue scopes = new(); + int tokenCalls; + + public string[] Scopes => this.scopes.ToArray(); + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) => + throw new NotSupportedException("Scheduler authentication must remain asynchronous."); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + this.scopes.Enqueue(Assert.Single(requestContext.Scopes)); + int call = Interlocked.Increment(ref this.tokenCalls); + return ValueTask.FromResult(new AccessToken( + "recorded-token", + expireFirstToken && call == 1 ? DateTimeOffset.UtcNow.AddMinutes(-1) : DateTimeOffset.UtcNow.AddHours(1), + refreshFirstToken && call == 1 ? DateTimeOffset.UtcNow.AddMinutes(-1) : null)); + } +} + +public sealed class SchedulerEnvironmentVariable : IDisposable +{ + readonly string name; + readonly string? originalValue; + + public SchedulerEnvironmentVariable(string name, string? value) + { + this.name = name; + this.originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(this.name, this.originalValue); +} + +public sealed class SchedulerAuthenticationServer : IAsyncLifetime +{ + static readonly Marshaller Marshaller = Marshallers.Create(value => value, value => value); + static readonly Method Call = new(MethodType.Unary, "test", "Call", Marshaller, Marshaller); + readonly Server server; + + public SchedulerAuthenticationServer() + { + this.server = new Server + { + Ports = { new ServerPort("127.0.0.1", 0, ServerCredentials.Insecure) }, + Services = + { + ServerServiceDefinition.CreateBuilder() + .AddMethod(Call, (byte[] _, ServerCallContext context) => + { + Assert.Equal("testhub", Assert.Single(context.RequestHeaders, h => h.Key == "taskhub").Value); + return Task.FromResult(System.Text.Encoding.UTF8.GetBytes(context.RequestHeaders.GetValue("authorization") ?? "")); + }) + .AddMethod(SandboxMethod("DeclareSandboxWorkerProfile", MethodType.Unary), this.ManageSandboxAsync) + .AddMethod(SandboxMethod("RemoveSandboxWorkerProfile", MethodType.Unary), this.ManageSandboxAsync) + .AddMethod( + SandboxMethod("ConnectSandboxActivityWorker", MethodType.ClientStreaming), + (IAsyncStreamReader requests, ServerCallContext context) => + this.RegistrationHandler!(requests, context)) + .Build(), + }, + }; + } + + public string Endpoint => $"http://127.0.0.1:{this.server.Ports.Single().BoundPort}"; + + public ConcurrentQueue ManagementHeaders { get; } = new(); + + public Func, ServerCallContext, Task>? RegistrationHandler { get; set; } + + public Task InitializeAsync() + { + this.server.Start(); + return Task.CompletedTask; + } + + public Task DisposeAsync() => this.server.ShutdownAsync(); + + public async Task CallAsync(GrpcChannel channel) + { + using AsyncUnaryCall call = channel.CreateCallInvoker().AsyncUnaryCall( + Call, null, new CallOptions(deadline: DateTime.UtcNow.AddSeconds(10)), []); + return await call.ResponseAsync; + } + + static Method SandboxMethod(string name, MethodType type) => + new(type, "microsoft.durabletask.sandboxes.SandboxActivities", name, Marshaller, Marshaller); + + Task ManageSandboxAsync(byte[] request, ServerCallContext context) + { + this.ManagementHeaders.Enqueue(context.RequestHeaders); + return Task.FromResult(Array.Empty()); + } +} diff --git a/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerExtensionsTests.cs b/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerExtensionsTests.cs index 0c1d72805..53461aa1e 100644 --- a/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerExtensionsTests.cs +++ b/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerExtensionsTests.cs @@ -6,6 +6,7 @@ using Azure.Identity; using FluentAssertions; using Grpc.Net.Client; +using Microsoft.DurableTask.AzureManaged.Tests; using Microsoft.DurableTask.Worker.Grpc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -14,15 +15,21 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Tests; +[Collection("Scheduler authentication environment")] public class DurableTaskSchedulerWorkerExtensionsTests { const string ValidEndpoint = "myaccount.westus3.durabletask.io"; const string ValidTaskHub = "testhub"; - [Fact] - public async Task UseDurableTaskScheduler_WithEndpointAndCredential_ShouldConfigureCorrectly() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public async Task UseDurableTaskScheduler_WithEndpointAndCredential_ShouldConfigureCorrectly(string? region, string expectedResourceId) { // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); ServiceCollection services = new ServiceCollection(); Mock mockBuilder = new Mock(); mockBuilder.Setup(b => b.Services).Returns(services); @@ -41,14 +48,19 @@ public async Task UseDurableTaskScheduler_WithEndpointAndCredential_ShouldConfig workerOptions.EndpointAddress.Should().Be(ValidEndpoint); workerOptions.TaskHubName.Should().Be(ValidTaskHub); workerOptions.Credential.Should().BeOfType(); - workerOptions.ResourceId.Should().Be("https://durabletask.io"); + workerOptions.ResourceId.Should().Be(expectedResourceId); workerOptions.AllowInsecureCredentials.Should().BeFalse(); } - [Fact] - public async Task UseDurableTaskScheduler_WithConnectionString_ShouldConfigureCorrectly() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public async Task UseDurableTaskScheduler_WithConnectionString_ShouldConfigureCorrectly(string? region, string expectedResourceId) { // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); ServiceCollection services = new ServiceCollection(); Mock mockBuilder = new Mock(); mockBuilder.Setup(b => b.Services).Returns(services); @@ -67,14 +79,19 @@ public async Task UseDurableTaskScheduler_WithConnectionString_ShouldConfigureCo workerOptions.EndpointAddress.Should().Be(ValidEndpoint); workerOptions.TaskHubName.Should().Be(ValidTaskHub); workerOptions.Credential.Should().BeOfType(); - workerOptions.ResourceId.Should().Be("https://durabletask.io"); + workerOptions.ResourceId.Should().Be(expectedResourceId); workerOptions.AllowInsecureCredentials.Should().BeFalse(); } - [Fact] - public async Task UseDurableTaskScheduler_WithLocalhostConnectionString_ShouldConfigureCorrectly() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public async Task UseDurableTaskScheduler_WithLocalhostConnectionString_ShouldConfigureCorrectly(string? region, string expectedResourceId) { // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(b => b.Services).Returns(services); @@ -93,7 +110,7 @@ public async Task UseDurableTaskScheduler_WithLocalhostConnectionString_ShouldCo workerOptions.EndpointAddress.Should().Be("http://localhost"); workerOptions.TaskHubName.Should().Be(ValidTaskHub); workerOptions.Credential.Should().BeNull(); - workerOptions.ResourceId.Should().Be("https://durabletask.io"); + workerOptions.ResourceId.Should().Be(expectedResourceId); workerOptions.AllowInsecureCredentials.Should().BeTrue(); } @@ -120,10 +137,15 @@ public async Task UseDurableTaskScheduler_WithNullParameters_ShouldThrowOptionsV : "DataAnnotation validation failed for 'DurableTaskSchedulerWorkerOptions' members: 'TaskHubName' with the error: 'Task hub name is required'."); } - [Fact] - public async Task UseDurableTaskScheduler_WithNullCredential_ShouldSucceed() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public async Task UseDurableTaskScheduler_WithNullCredential_ShouldSucceed(string? region, string expectedResourceId) { // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); ServiceCollection services = new ServiceCollection(); Mock mockBuilder = new Mock(); mockBuilder.Setup(b => b.Services).Returns(services); @@ -139,7 +161,7 @@ public async Task UseDurableTaskScheduler_WithNullCredential_ShouldSucceed() workerOptions.EndpointAddress.Should().Be(ValidEndpoint); workerOptions.TaskHubName.Should().Be(ValidTaskHub); workerOptions.Credential.Should().BeNull(); - workerOptions.ResourceId.Should().Be("https://durabletask.io"); + workerOptions.ResourceId.Should().Be(expectedResourceId); workerOptions.AllowInsecureCredentials.Should().BeFalse(); } @@ -175,10 +197,15 @@ public void UseDurableTaskScheduler_WithNullOrEmptyConnectionString_ShouldThrowA action.Should().Throw(); } - [Fact] - public async Task UseDurableTaskScheduler_WithNamedOptions_ShouldConfigureCorrectly() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public async Task UseDurableTaskScheduler_WithNamedOptions_ShouldConfigureCorrectly(string? region, string expectedResourceId) { // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); ServiceCollection services = new ServiceCollection(); Mock mockBuilder = new Mock(); mockBuilder.Setup(b => b.Services).Returns(services); @@ -197,7 +224,7 @@ public async Task UseDurableTaskScheduler_WithNamedOptions_ShouldConfigureCorrec options.EndpointAddress.Should().Be(ValidEndpoint); // The https:// prefix is added by CreateChannel, not in the extension method options.TaskHubName.Should().Be(ValidTaskHub); options.Credential.Should().BeOfType(); - options.ResourceId.Should().Be("https://durabletask.io"); + options.ResourceId.Should().Be(expectedResourceId); options.AllowInsecureCredentials.Should().BeFalse(); } @@ -492,4 +519,3 @@ public async Task UseDurableTaskScheduler_DifferentWorkerId_UsesSeparateChannels .GetValue(internalOptions); } } - diff --git a/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerOptionsTests.cs b/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerOptionsTests.cs index 3cc03c035..8cfd1a0f4 100644 --- a/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerOptionsTests.cs +++ b/test/Worker/AzureManaged.Tests/DurableTaskSchedulerWorkerOptionsTests.cs @@ -3,10 +3,12 @@ using Azure.Identity; using FluentAssertions; +using Microsoft.DurableTask.AzureManaged.Tests; using Xunit; namespace Microsoft.DurableTask.Shared.AzureManaged.Tests; +[Collection("Scheduler authentication environment")] public class DurableTaskSchedulerWorkerOptionsTests { const string ValidEndpoint = "myaccount.westus3.durabletask.io"; @@ -188,14 +190,21 @@ public void FromConnectionString_WithNone_ShouldCreateInstanceWithNullCredential options.Credential.Should().BeNull(); } - [Fact] - public void DefaultProperties_ShouldHaveExpectedValues() + [Theory] + [InlineData(null, "https://durabletask.io")] + [InlineData("westus2", "https://durabletask.io")] + [InlineData("UsGovVirginia", "https://durabletask.azure.us")] + [InlineData("UsDodCentral", "https://durabletask.azure.us")] + public void DefaultProperties_ShouldHaveExpectedValues(string? region, string expectedResourceId) { - // Arrange & Act + // Arrange + using SchedulerEnvironmentVariable regionVariable = new("REGION_NAME", region); + + // Act DurableTaskSchedulerWorkerOptions options = new DurableTaskSchedulerWorkerOptions(); // Assert - options.ResourceId.Should().Be("https://durabletask.io"); + options.ResourceId.Should().Be(expectedResourceId); options.WorkerId.Should().NotBeNullOrEmpty(); options.WorkerId.Should().Contain(Environment.MachineName); options.WorkerId.Should().Contain(Environment.ProcessId.ToString()); diff --git a/test/Worker/AzureManaged.Tests/SandboxAuthenticationTests.cs b/test/Worker/AzureManaged.Tests/SandboxAuthenticationTests.cs new file mode 100644 index 000000000..5d0c3531d --- /dev/null +++ b/test/Worker/AzureManaged.Tests/SandboxAuthenticationTests.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.DurableTask.AzureManaged.Tests; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; +using Microsoft.DurableTask.Worker.Grpc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Tests; + +[Collection("Scheduler authentication environment")] +public class SandboxAuthenticationTests(SchedulerAuthenticationServer server) : IClassFixture +{ + [Theory] + [InlineData(null, "https://durabletask.azure.us/.default", false)] + [InlineData("https://durabletask.io", "https://durabletask.io/.default", false)] + [InlineData("api://Custom/.default/.DEFAULT/", "api://Custom/.default/.default", false)] + [InlineData("https://durabletask.azure.us", null, true)] + public async Task Registration_ReconnectsWithSchedulerAudienceAndSharesTokenCacheAsync( + string? resourceId, string? expectedScope, bool useCallerSuppliedChannel) + { + // Arrange + using SchedulerEnvironmentVariable region = new("REGION_NAME", "UsDodCentral"); + using SchedulerEnvironmentVariable endpoint = new("DTS_ENDPOINT", server.Endpoint); + using SchedulerEnvironmentVariable taskHub = new("DTS_TASK_HUB", "testhub"); + using SchedulerEnvironmentVariable workerProfile = new("DTS_WORKER_PROFILE_ID", "profile"); + using SchedulerEnvironmentVariable authentication = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using SchedulerEnvironmentVariable identity = new("DTS_UMI_CLIENT_ID", "11111111-1111-1111-1111-111111111111"); + using SchedulerEnvironmentVariable sandboxProvider = new("DTS_SANDBOX_PROVIDER", "Sandbox"); + using SchedulerEnvironmentVariable sandboxId = new("DTS_SANDBOX_ID", "sandbox"); + RecordingSchedulerCredential credential = new(expireFirstToken: true); + ServiceCollection services = new(); + services.AddSingleton(NullLoggerFactory.Instance); + services.Configure(registry => + registry.AddActivityFunc("Hello", (_, input) => input)); + Mock builder = new(); + builder.SetupGet(b => b.Services).Returns(services); + builder.SetupGet(b => b.Name).Returns(Options.DefaultName); + builder.Object.UseSandboxWorker(); + services.Configure(options => options.ResourceId = resourceId); + using GrpcChannel suppliedChannel = GrpcChannel.ForAddress(server.Endpoint); + if (useCallerSuppliedChannel) + { + services.PostConfigure( + options => options.CallInvoker = suppliedChannel.CreateCallInvoker()); + } + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskSchedulerWorkerOptions schedulerOptions = + provider.GetRequiredService>().CurrentValue; + Assert.IsType(schedulerOptions.Credential); + schedulerOptions.Credential = credential; + schedulerOptions.AllowInsecureCredentials = true; + SandboxActivityWorkerRegistrationHostedService registration = Assert.Single( + provider.GetServices().OfType()); + GrpcChannel channel = Assert.IsType( + provider.GetRequiredService>().CurrentValue.Channel); + TaskCompletionSource firstRegistration = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource disconnect = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource secondRegistration = new(TaskCreationOptions.RunContinuationsAsynchronously); + int registrations = 0; + server.RegistrationHandler = async (requests, context) => + { + Assert.True(await requests.MoveNext(context.CancellationToken)); + Assert.Equal("testhub", Assert.Single(context.RequestHeaders, header => header.Key == "taskhub").Value); + Assert.Equal(useCallerSuppliedChannel ? null : "Bearer recorded-token", context.RequestHeaders.GetValue("authorization")); + if (Interlocked.Increment(ref registrations) == 1) + { + firstRegistration.TrySetResult(); + await disconnect.Task.WaitAsync(context.CancellationToken); + throw new RpcException(new Status(StatusCode.Unavailable, "Test reconnect")); + } + + secondRegistration.TrySetResult(); + while (await requests.MoveNext(context.CancellationToken)) + { + // Drain heartbeats until the worker completes the registration stream. + } + + return []; + }; + Assert.Empty(credential.Scopes); + + try + { + // Act + await registration.StartAsync(CancellationToken.None); + await firstRegistration.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Environment.SetEnvironmentVariable("REGION_NAME", "westus2"); + disconnect.SetResult(); + await secondRegistration.Task.WaitAsync(TimeSpan.FromSeconds(10)); + if (!useCallerSuppliedChannel) + { + await server.CallAsync(channel); + } + + // Assert + Assert.Equal(useCallerSuppliedChannel ? [] : new[] { expectedScope, expectedScope }, credential.Scopes); + Assert.Equal(2, registrations); + } + finally + { + using CancellationTokenSource stop = new(TimeSpan.FromSeconds(10)); + await registration.StopAsync(stop.Token); + server.RegistrationHandler = null; + } + } +} diff --git a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj index fe1183c34..52412c3ae 100644 --- a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj +++ b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj @@ -2,6 +2,7 @@ net10.0 + $(DefineConstants);SCHEDULER_WORKER @@ -10,6 +11,7 @@ +