From 56b7f36ea230e93617cfb4003b0b87dce84c0b22 Mon Sep 17 00:00:00 2001 From: Bernd Verst Date: Sat, 26 Sep 2026 00:03:56 -0700 Subject: [PATCH 1/2] Add configurable Azure Managed token audiences Normalize resource IDs across options, connection strings, and client/worker helpers; select government defaults per instance and forward supported credential authorities. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 + README.md | 93 +++++ .../DurableTaskSchedulerClientExtensions.java | 47 +++ .../DurableTaskSchedulerClientOptions.java | 27 +- .../DurableTaskSchedulerConnectionString.java | 58 ++- .../DurableTaskSchedulerWorkerExtensions.java | 47 +++ .../DurableTaskSchedulerWorkerOptions.java | 26 +- .../durabletask/azuremanaged/ResourceId.java | 54 +++ .../azuremanaged/AudienceTransportTest.java | 116 ++++++ .../ConnectionStringAuthenticationTest.java | 145 +++++++ ...DurableTaskSchedulerWorkerOptionsTest.java | 2 +- .../azuremanaged/ResourceIdTest.java | 361 ++++++++++++++++++ 12 files changed, 958 insertions(+), 20 deletions(-) create mode 100644 azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/ResourceId.java create mode 100644 azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/AudienceTransportTest.java create mode 100644 azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ConnectionStringAuthenticationTest.java create mode 100644 azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ResourceIdTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f1deaf3b..a0544ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## Unreleased +* Add normalized token audience configuration through Azure Managed client/worker options, convenience overloads, and connection-string `ResourceId`. Missing or empty audiences now default to `https://durabletask.azure.us` when `REGION_NAME` starts with `usgov` or `usdod` (case-insensitively), otherwise `https://durabletask.io`. Explicit audiences override this default; whitespace-only or otherwise empty normalized values are rejected. +* Add optional connection-string `AuthorityHost` forwarding for Azure Identity credentials that support it. Audience selection does not change the service endpoint or credential authority; managed identity and developer-tool cloud configuration remain separate. * Add the `exporthistory` module for durable, checkpointed export of terminal orchestration history to Azure Blob Storage ([#293](https://github.com/microsoft/durabletask-java/pull/293)) * Add client APIs to list terminal instance IDs by completion time (`listInstanceIds`) and read orchestration history (`getOrchestrationHistory`) ([#292](https://github.com/microsoft/durabletask-java/pull/292)) * Add `createReplaySafeLogger` to suppress orchestration log output during replay ([#295](https://github.com/microsoft/durabletask-java/pull/295)). diff --git a/README.md b/README.md index 7f5b8b53..70a101e6 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,99 @@ The following packages are produced from this repo. | Durable Task - Client | [![Maven Central](https://img.shields.io/maven-central/v/com.microsoft/durabletask-client?label=durabletask-client)](https://mvnrepository.com/artifact/com.microsoft/durabletask-client/1.0.0) | | Durable Task - Azure Functions | [![Maven Central](https://img.shields.io/maven-central/v/com.microsoft/durabletask-azure-functions?label=durabletask-azure-functions)](https://mvnrepository.com/artifact/com.microsoft/durabletask-azure-functions/1.0.1) | +## Azure Durable Task Scheduler authentication + +The `com.microsoft:durabletask-azuremanaged` package configures clients and workers +through `DurableTaskSchedulerClientOptions`, `DurableTaskSchedulerWorkerOptions`, +or the corresponding `DurableTaskSchedulerClientExtensions` and +`DurableTaskSchedulerWorkerExtensions` convenience methods. + +### Token audience + +Set `resourceId` using `setResourceId(...)` on either options class, the optional +last argument of the `createClientBuilder`, `createWorkerBuilder`, and +`useDurableTaskScheduler` overloads, or `ResourceId` in a connection string. +This is a **token audience URI**, not an Azure Resource Manager resource path. +Existing overloads remain supported. + +| Configuration | Selected audience | +| --- | --- | +| Explicit nonempty `resourceId` / `ResourceId` | The normalized explicit value | +| Missing, null, or empty value, with `REGION_NAME` starting with `usgov` or `usdod` (case-insensitive) | `https://durabletask.azure.us` | +| All other cases | `https://durabletask.io` | + +**Default behavior change:** applications running in US Government or DoD regions +now select the government audience when no explicit audience is provided. +Set `ResourceId=https://durabletask.io` to retain the public audience in those +regions. Prefixes, not substrings, are matched: `chinaeast2`, `notusgov`, and +`notusdod` still use the public default. No audience is inferred from the endpoint. + +Explicit values have surrounding whitespace and trailing `/` characters removed, +then one existing `/.default` suffix removed case-insensitively, followed by any +remaining trailing `/` characters. URI casing is otherwise preserved. For example, +`https://durabletask.azure.us//.DEFAULT//` requests +`https://durabletask.azure.us/.default`, and `api://CustomAudience/resource/.DEFAULT/` +requests `api://CustomAudience/resource/.default`. Whitespace-only input, `///`, +`/.default`, and `/.DEFAULT///` throw `IllegalArgumentException`; use an omitted +or genuinely empty value for the default. + +Defaults are resolved per options instance or parsed connection string, rather +than at class initialization. Setting a null or empty audience explicitly resolves +the default again at that point. The selected audience is retained when creating +channels, refreshing tokens, and reconnecting. Connection-string conversion does +not normalize the audience again. + +### Government-cloud example and credential authority + +The **audience**, **credential authority/cloud**, and **service endpoint** are +independent settings. Neither `resourceId` nor `REGION_NAME` changes the endpoint +or credential authority. For an already-created `TokenCredential`, configure +authority on that credential; token requests do not override it. + +```java +import com.azure.core.credential.TokenCredential; +import com.azure.identity.AzureAuthorityHosts; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.microsoft.durabletask.DurableTaskGrpcClientBuilder; +import com.microsoft.durabletask.DurableTaskGrpcWorkerBuilder; +import com.microsoft.durabletask.azuremanaged.DurableTaskSchedulerClientExtensions; +import com.microsoft.durabletask.azuremanaged.DurableTaskSchedulerWorkerExtensions; + +// Set these to your scheduler's actual endpoint and task hub. +String endpoint = System.getenv("DTS_ENDPOINT"); +String taskHub = System.getenv("DTS_TASK_HUB"); +TokenCredential credential = new DefaultAzureCredentialBuilder() + .authorityHost(AzureAuthorityHosts.AZURE_GOVERNMENT) + .build(); + +DurableTaskGrpcClientBuilder clientBuilder = + DurableTaskSchedulerClientExtensions.createClientBuilder( + endpoint, taskHub, credential, "https://durabletask.azure.us"); +DurableTaskGrpcWorkerBuilder workerBuilder = + DurableTaskSchedulerWorkerExtensions.createWorkerBuilder( + endpoint, taskHub, credential, "https://durabletask.azure.us"); +``` + +When the SDK constructs the credential from a connection string, use the optional +`AuthorityHost` property: + +```text +Endpoint=;TaskHub=;Authentication=DefaultAzure;ResourceId=https://durabletask.azure.us;AuthorityHost=https://login.microsoftonline.us/ +``` + +`AuthorityHost` is forwarded to Azure Identity for `DefaultAzure`, `Environment`, +`WorkloadIdentity`, and `InteractiveBrowser` authentication. Omission or an empty +value leaves Azure Identity's defaults intact, including `AZURE_AUTHORITY_HOST` +where supported. It is not an authority override on client or worker options. + +Managed identity uses the hosting environment's identity endpoint; an Entra +authority override does not apply. Developer-tool credentials (`AzureCli`, +`AzurePowerShell`, `VisualStudioCode`, and `IntelliJ`) use those tools' cloud +configuration, not the connection string's `AuthorityHost`. Configure them +separately, including when they are used by `DefaultAzureCredential` (for example, +`az cloud set --name AzureUSGovernment` before signing in with Azure CLI). +`Authentication=None` remains anonymous. + ## Getting started with Azure Functions For information about how to get started with Durable Functions for Java, see the [Azure Functions README.md](/azurefunctions/README.md) content. diff --git a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerClientExtensions.java b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerClientExtensions.java index 7e749603..0207a8b0 100644 --- a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerClientExtensions.java +++ b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerClientExtensions.java @@ -48,6 +48,29 @@ public static void useDurableTaskScheduler( String endpoint, String taskHubName, @Nullable TokenCredential tokenCredential) { + useDurableTaskScheduler(builder, endpoint, taskHubName, tokenCredential, null); + } + + /** + * Configures a client builder with an explicit token audience. + * + * @param builder The builder to configure. + * @param endpoint The service endpoint, independent of the audience and credential authority. + * @param taskHubName The name of the task hub. + * @param tokenCredential The credential, with its authority/cloud configured by the caller, + * or null for anonymous access. + * @param resourceId The token audience URI, or null/empty for the region-based default. + * See {@link DurableTaskSchedulerClientOptions#setResourceId(String)} + * for normalization and default selection. + * @throws NullPointerException if builder, endpoint, or taskHubName is null. + * @throws IllegalArgumentException if resourceId becomes empty after normalization. + */ + public static void useDurableTaskScheduler( + DurableTaskGrpcClientBuilder builder, + String endpoint, + String taskHubName, + @Nullable TokenCredential tokenCredential, + @Nullable String resourceId) { Objects.requireNonNull(builder, "builder must not be null"); Objects.requireNonNull(endpoint, "endpoint must not be null"); Objects.requireNonNull(taskHubName, "taskHubName must not be null"); @@ -55,6 +78,7 @@ public static void useDurableTaskScheduler( configureBuilder(builder, new DurableTaskSchedulerClientOptions() .setEndpointAddress(endpoint) .setTaskHubName(taskHubName) + .setResourceId(resourceId) .setCredential(tokenCredential)); } @@ -85,12 +109,35 @@ public static DurableTaskGrpcClientBuilder createClientBuilder( String endpoint, String taskHubName, @Nullable TokenCredential tokenCredential) { + return createClientBuilder(endpoint, taskHubName, tokenCredential, null); + } + + /** + * Creates a client builder with an explicit token audience. + * + * @param endpoint The service endpoint, independent of the audience and credential authority. + * @param taskHubName The name of the task hub. + * @param tokenCredential The credential, with its authority/cloud configured by the caller, + * or null for anonymous access. + * @param resourceId The token audience URI, or null/empty for the region-based default. + * See {@link DurableTaskSchedulerClientOptions#setResourceId(String)} + * for normalization and default selection. + * @return A new configured DurableTaskGrpcClientBuilder instance. + * @throws NullPointerException if endpoint or taskHubName is null. + * @throws IllegalArgumentException if resourceId becomes empty after normalization. + */ + public static DurableTaskGrpcClientBuilder createClientBuilder( + String endpoint, + String taskHubName, + @Nullable TokenCredential tokenCredential, + @Nullable String resourceId) { Objects.requireNonNull(endpoint, "endpoint must not be null"); Objects.requireNonNull(taskHubName, "taskHubName must not be null"); return createBuilderFromOptions(new DurableTaskSchedulerClientOptions() .setEndpointAddress(endpoint) .setTaskHubName(taskHubName) + .setResourceId(resourceId) .setCredential(tokenCredential) .setAllowInsecureCredentials(tokenCredential == null)); } diff --git a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerClientOptions.java b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerClientOptions.java index 0e6cd24b..55682cd7 100644 --- a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerClientOptions.java +++ b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerClientOptions.java @@ -19,12 +19,15 @@ public class DurableTaskSchedulerClientOptions { private String taskHubName = ""; private TokenCredential credential; - private String resourceId = "https://durabletask.io"; + private String resourceId = ResourceId.getDefault(); private boolean allowInsecureCredentials = false; private Duration tokenRefreshMargin = Duration.ofMinutes(5); /** * Creates a new instance of DurableTaskSchedulerClientOptions. + * Resolves the token audience from {@code REGION_NAME} for this instance. + * + * @see #setResourceId(String) */ public DurableTaskSchedulerClientOptions() { } @@ -47,11 +50,12 @@ public static DurableTaskSchedulerClientOptions fromConnectionString(String conn * @return A new DurableTaskSchedulerClientOptions object. */ static DurableTaskSchedulerClientOptions fromConnectionString(DurableTaskSchedulerConnectionString connectionString) { - // TODO: Parse different credential types from connection string DurableTaskSchedulerClientOptions options = new DurableTaskSchedulerClientOptions(); options.setEndpointAddress(connectionString.getEndpoint()); options.setTaskHubName(connectionString.getTaskHubName()); options.setCredential(connectionString.getCredential()); + // The connection string has already resolved and normalized this audience. + options.resourceId = connectionString.getResourceId(); options.setAllowInsecureCredentials(options.getCredential() == null); return options; } @@ -108,7 +112,8 @@ public TokenCredential getCredential() { /** * Sets the credential used for authentication. * - * @param credential The credential. + * @param credential The credential, or null for anonymous access. Configure the authority/cloud + * on the credential itself; the resource ID does not change its authority. * @return This options object. */ public DurableTaskSchedulerClientOptions setCredential(TokenCredential credential) { @@ -117,7 +122,7 @@ public DurableTaskSchedulerClientOptions setCredential(TokenCredential credentia } /** - * Gets the resource ID. + * Gets the normalized token audience URI (not an Azure Resource Manager resource path). * * @return The resource ID. */ @@ -126,13 +131,19 @@ public String getResourceId() { } /** - * Sets the resource ID. - * - * @param resourceId The resource ID. + * Sets the token audience URI, independently of the endpoint and credential authority. + * Surrounding whitespace, trailing slashes, and one case-insensitive {@code /.default} + * suffix are removed. Token requests append {@code /.default} to the result. + * + * @param resourceId The audience URI. Null or empty selects {@code https://durabletask.azure.us} + * when {@code REGION_NAME} starts with {@code usgov} or {@code usdod} + * (case-insensitively), otherwise {@code https://durabletask.io}. + * The selection is retained for subsequent channels and token refreshes. * @return This options object. + * @throws IllegalArgumentException if a nonempty value becomes empty after normalization. */ public DurableTaskSchedulerClientOptions setResourceId(String resourceId) { - this.resourceId = resourceId; + this.resourceId = ResourceId.resolve(resourceId); return this; } diff --git a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerConnectionString.java b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerConnectionString.java index e8e5696c..ddb0f1f3 100644 --- a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerConnectionString.java +++ b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerConnectionString.java @@ -23,9 +23,12 @@ /** * Represents the constituent parts of a connection string for a Durable Task Scheduler service. + * Supports optional {@code ResourceId} (token audience URI) and {@code AuthorityHost} + * (credential authority) properties, independent of {@code Endpoint}. */ public class DurableTaskSchedulerConnectionString { private final Map properties; + private final String resourceId; /** * Initializes a new instance of the DurableTaskSchedulerConnectionString class. @@ -43,6 +46,7 @@ public DurableTaskSchedulerConnectionString(String connectionString) { this.getAuthentication(); this.getTaskHubName(); this.getEndpoint(); + this.resourceId = ResourceId.resolve(getValue("ResourceId")); } /** @@ -113,6 +117,33 @@ public String getTaskHubName() { return getRequiredValue("TaskHub"); } + /** + * Gets the normalized token audience URI, not an Azure Resource Manager resource path. + * Surrounding whitespace, trailing slashes, and one case-insensitive {@code /.default} + * suffix are removed from explicit values. Null or empty values select + * {@code https://durabletask.azure.us} when {@code REGION_NAME} starts with + * {@code usgov} or {@code usdod} (case-insensitively), otherwise {@code https://durabletask.io}. + * The default is resolved when this connection string is parsed, not at token refresh. + * + * @return The normalized resource ID. + */ + public String getResourceId() { + return resourceId; + } + + /** + * Gets the optional Microsoft Entra authority host for credentials created by this connection string. + * Applies to DefaultAzure, Environment, WorkloadIdentity, and InteractiveBrowser authentication. + * When omitted or empty, Azure Identity retains its default behavior, including + * {@code AZURE_AUTHORITY_HOST} where supported. Managed identity uses its hosting environment's + * identity endpoint; developer-tool credentials require the tools' own cloud configuration. + * + * @return The authority host, or null if not specified. This does not change the endpoint or audience. + */ + public @Nullable String getAuthorityHost() { + return getValue("AuthorityHost"); + } + private String getValue(String name) { return properties.get(name); } @@ -133,7 +164,9 @@ private static Map parseConnectionString(String connectionString int equalsIndex = pair.indexOf('='); if (equalsIndex > 0) { String key = pair.substring(0, equalsIndex).trim(); - String value = pair.substring(equalsIndex + 1).trim(); + // Preserve whitespace-only ResourceId values so normalization can reject them. + String rawValue = pair.substring(equalsIndex + 1); + String value = key.equals("ResourceId") ? rawValue : rawValue.trim(); properties.put(key, value); } } @@ -149,15 +182,24 @@ private static Map parseConnectionString(String connectionString */ public @Nullable TokenCredential getCredential() { String authType = getAuthentication(); + String authorityHost = getAuthorityHost(); + boolean hasAuthorityHost = authorityHost != null && !authorityHost.isEmpty(); // Parse the supported auth types in a case-insensitive way switch (authType.toLowerCase().trim()) { case "defaultazure": - return new DefaultAzureCredentialBuilder().build(); // CodeQL [SM05141] Use DefaultAzureCredential explicitly for local development and is decided by the user + DefaultAzureCredentialBuilder defaultBuilder = new DefaultAzureCredentialBuilder(); + if (hasAuthorityHost) { + defaultBuilder.authorityHost(authorityHost); + } + return defaultBuilder.build(); // CodeQL [SM05141] Use DefaultAzureCredential explicitly for local development and is decided by the user case "managedidentity": return new ManagedIdentityCredentialBuilder().clientId(getClientId()).build(); case "workloadidentity": WorkloadIdentityCredentialBuilder builder = new WorkloadIdentityCredentialBuilder(); + if (hasAuthorityHost) { + builder.authorityHost(authorityHost); + } if (getClientId() != null && !getClientId().isEmpty()) { builder.clientId(getClientId()); } @@ -178,7 +220,11 @@ private static Map parseConnectionString(String connectionString return builder.build(); case "environment": - return new EnvironmentCredentialBuilder().build(); + EnvironmentCredentialBuilder environmentBuilder = new EnvironmentCredentialBuilder(); + if (hasAuthorityHost) { + environmentBuilder.authorityHost(authorityHost); + } + return environmentBuilder.build(); case "azurecli": return new AzureCliCredentialBuilder().build(); case "azurepowershell": @@ -188,7 +234,11 @@ private static Map parseConnectionString(String connectionString case "intellij": return new IntelliJCredentialBuilder().build(); case "interactivebrowser": - return new InteractiveBrowserCredentialBuilder().build(); + InteractiveBrowserCredentialBuilder browserBuilder = new InteractiveBrowserCredentialBuilder(); + if (hasAuthorityHost) { + browserBuilder.authorityHost(authorityHost); + } + return browserBuilder.build(); case "none": return null; default: diff --git a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerExtensions.java b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerExtensions.java index 97b3593d..7052e30a 100644 --- a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerExtensions.java +++ b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerExtensions.java @@ -48,6 +48,29 @@ public static void useDurableTaskScheduler( String endpoint, String taskHubName, @Nullable TokenCredential tokenCredential) { + useDurableTaskScheduler(builder, endpoint, taskHubName, tokenCredential, null); + } + + /** + * Configures a worker builder with an explicit token audience. + * + * @param builder The builder to configure. + * @param endpoint The service endpoint, independent of the audience and credential authority. + * @param taskHubName The name of the task hub. + * @param tokenCredential The credential, with its authority/cloud configured by the caller, + * or null for anonymous access. + * @param resourceId The token audience URI, or null/empty for the region-based default. + * See {@link DurableTaskSchedulerWorkerOptions#setResourceId(String)} + * for normalization and default selection. + * @throws NullPointerException if builder, endpoint, or taskHubName is null. + * @throws IllegalArgumentException if resourceId becomes empty after normalization. + */ + public static void useDurableTaskScheduler( + DurableTaskGrpcWorkerBuilder builder, + String endpoint, + String taskHubName, + @Nullable TokenCredential tokenCredential, + @Nullable String resourceId) { Objects.requireNonNull(builder, "builder must not be null"); Objects.requireNonNull(endpoint, "endpoint must not be null"); Objects.requireNonNull(taskHubName, "taskHubName must not be null"); @@ -55,6 +78,7 @@ public static void useDurableTaskScheduler( configureBuilder(builder, new DurableTaskSchedulerWorkerOptions() .setEndpointAddress(endpoint) .setTaskHubName(taskHubName) + .setResourceId(resourceId) .setCredential(tokenCredential)); } @@ -85,12 +109,35 @@ public static DurableTaskGrpcWorkerBuilder createWorkerBuilder( String endpoint, String taskHubName, @Nullable TokenCredential tokenCredential) { + return createWorkerBuilder(endpoint, taskHubName, tokenCredential, null); + } + + /** + * Creates a worker builder with an explicit token audience. + * + * @param endpoint The service endpoint, independent of the audience and credential authority. + * @param taskHubName The name of the task hub. + * @param tokenCredential The credential, with its authority/cloud configured by the caller, + * or null for anonymous access. + * @param resourceId The token audience URI, or null/empty for the region-based default. + * See {@link DurableTaskSchedulerWorkerOptions#setResourceId(String)} + * for normalization and default selection. + * @return A new configured DurableTaskGrpcWorkerBuilder instance. + * @throws NullPointerException if endpoint or taskHubName is null. + * @throws IllegalArgumentException if resourceId becomes empty after normalization. + */ + public static DurableTaskGrpcWorkerBuilder createWorkerBuilder( + String endpoint, + String taskHubName, + @Nullable TokenCredential tokenCredential, + @Nullable String resourceId) { Objects.requireNonNull(endpoint, "endpoint must not be null"); Objects.requireNonNull(taskHubName, "taskHubName must not be null"); return createBuilderFromOptions(new DurableTaskSchedulerWorkerOptions() .setEndpointAddress(endpoint) .setTaskHubName(taskHubName) + .setResourceId(resourceId) .setCredential(tokenCredential) .setAllowInsecureCredentials(tokenCredential == null)); } diff --git a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerOptions.java b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerOptions.java index d0c34af3..c8d4962c 100644 --- a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerOptions.java +++ b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerOptions.java @@ -30,12 +30,15 @@ public class DurableTaskSchedulerWorkerOptions { private String taskHubName = ""; private TokenCredential credential; - private String resourceId = "https://durabletask.io"; + private String resourceId = ResourceId.getDefault(); private boolean allowInsecureCredentials = false; private Duration tokenRefreshMargin = Duration.ofMinutes(5); /** * Creates a new instance of DurableTaskSchedulerWorkerOptions. + * Resolves the token audience from {@code REGION_NAME} for this instance. + * + * @see #setResourceId(String) */ public DurableTaskSchedulerWorkerOptions() { } @@ -62,6 +65,8 @@ static DurableTaskSchedulerWorkerOptions fromConnectionString(DurableTaskSchedul options.setEndpointAddress(connectionString.getEndpoint()); options.setTaskHubName(connectionString.getTaskHubName()); options.setCredential(connectionString.getCredential()); + // The connection string has already resolved and normalized this audience. + options.resourceId = connectionString.getResourceId(); options.setAllowInsecureCredentials(options.getCredential() == null); return options; } @@ -118,7 +123,8 @@ public TokenCredential getCredential() { /** * Sets the credential used for authentication. * - * @param credential The credential. + * @param credential The credential, or null for anonymous access. Configure the authority/cloud + * on the credential itself; the resource ID does not change its authority. * @return This options object. */ public DurableTaskSchedulerWorkerOptions setCredential(TokenCredential credential) { @@ -127,7 +133,7 @@ public DurableTaskSchedulerWorkerOptions setCredential(TokenCredential credentia } /** - * Gets the resource ID. + * Gets the normalized token audience URI (not an Azure Resource Manager resource path). * * @return The resource ID. */ @@ -136,13 +142,19 @@ public String getResourceId() { } /** - * Sets the resource ID. - * - * @param resourceId The resource ID. + * Sets the token audience URI, independently of the endpoint and credential authority. + * Surrounding whitespace, trailing slashes, and one case-insensitive {@code /.default} + * suffix are removed. Token requests append {@code /.default} to the result. + * + * @param resourceId The audience URI. Null or empty selects {@code https://durabletask.azure.us} + * when {@code REGION_NAME} starts with {@code usgov} or {@code usdod} + * (case-insensitively), otherwise {@code https://durabletask.io}. + * The selection is retained for subsequent channels and token refreshes. * @return This options object. + * @throws IllegalArgumentException if a nonempty value becomes empty after normalization. */ public DurableTaskSchedulerWorkerOptions setResourceId(String resourceId) { - this.resourceId = resourceId; + this.resourceId = ResourceId.resolve(resourceId); return this; } diff --git a/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/ResourceId.java b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/ResourceId.java new file mode 100644 index 00000000..e143abae --- /dev/null +++ b/azuremanaged/src/main/java/com/microsoft/durabletask/azuremanaged/ResourceId.java @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask.azuremanaged; + +/** + * Resolves token audiences at configuration boundaries, before scopes are created. + */ +final class ResourceId { + private static final String DEFAULT_SCOPE_SUFFIX = "/.default"; + + private ResourceId() { + } + + static String getDefault() { + return getDefault(System.getenv("REGION_NAME")); + } + + static String getDefault(String regionName) { + if (regionName != null + && (regionName.regionMatches(true, 0, "usgov", 0, 5) + || regionName.regionMatches(true, 0, "usdod", 0, 5))) { + return "https://durabletask.azure.us"; + } + return "https://durabletask.io"; + } + + static String resolve(String resourceId) { + if (resourceId == null || resourceId.isEmpty()) { + return getDefault(); + } + + String normalized = trimTrailingSlashes(resourceId.trim()); + if (normalized.regionMatches(true, normalized.length() - DEFAULT_SCOPE_SUFFIX.length(), + DEFAULT_SCOPE_SUFFIX, 0, DEFAULT_SCOPE_SUFFIX.length())) { + normalized = trimTrailingSlashes( + normalized.substring(0, normalized.length() - DEFAULT_SCOPE_SUFFIX.length())); + } + if (normalized.isEmpty()) { + throw new IllegalArgumentException( + "ResourceId must not be empty after normalization. Specify a token audience URI, " + + "or omit ResourceId to use the region-based default."); + } + return normalized; + } + + private static String trimTrailingSlashes(String value) { + int end = value.length(); + while (end > 0 && value.charAt(end - 1) == '/') { + end--; + } + return value.substring(0, end); + } +} diff --git a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/AudienceTransportTest.java b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/AudienceTransportTest.java new file mode 100644 index 00000000..9c1c7618 --- /dev/null +++ b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/AudienceTransportTest.java @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask.azuremanaged; + +import com.google.protobuf.Empty; +import com.microsoft.durabletask.DurableTaskClient; +import com.microsoft.durabletask.DurableTaskGrpcWorker; +import io.grpc.CallOptions; +import io.grpc.Grpc; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerServiceDefinition; +import io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.ServerCalls; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.ArrayList; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AudienceTransportTest { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void localGrpcReconnectRetainsScopeAndTokenCache(boolean worker) throws Exception { + List authorizations = Collections.synchronizedList(new ArrayList<>()); + Set connections = Collections.synchronizedSet(new HashSet<>()); + Server server = NettyServerBuilder.forAddress(new InetSocketAddress("127.0.0.1", 0)) + .addService(ServerServiceDefinition.builder("test.Service") + .addMethod(ResourceIdTest.METHOD, ServerCalls.asyncUnaryCall((request, response) -> { + response.onNext(Empty.getDefaultInstance()); + response.onCompleted(); + })) + .build()) + .intercept(new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + authorizations.add(headers.get(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER))); + connections.add(call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR)); + return next.startCall(call, headers); + } + }) + .build().start(); + ManagedChannel channel = null; + try (MockedStatic defaults = mockStatic(ResourceId.class, CALLS_REAL_METHODS)) { + defaults.when(ResourceId::getDefault).thenReturn("https://durabletask.azure.us"); + ResourceIdTest.RecordingCredential credential = new ResourceIdTest.RecordingCredential(); + String endpoint = "http://127.0.0.1:" + server.getPort(); + channel = (ManagedChannel) (worker + ? new DurableTaskSchedulerWorkerOptions().setEndpointAddress(endpoint) + .setTaskHubName("test-hub").setCredential(credential).createGrpcChannel() + : new DurableTaskSchedulerClientOptions().setEndpointAddress(endpoint) + .setAllowInsecureCredentials(true).setTaskHubName("test-hub") + .setCredential(credential).createGrpcChannel()); + assertTrue(credential.scopes.isEmpty()); + call(channel); + defaults.when(ResourceId::getDefault).thenReturn("https://durabletask.io"); + channel.enterIdle(); + call(channel); + call(channel); + assertEquals(Arrays.asList("Bearer token-1", "Bearer token-2", "Bearer token-2"), authorizations); + assertEquals(Collections.nCopies(2, "https://durabletask.azure.us/.default"), credential.scopes); + assertEquals(2, connections.size(), "The second call must use a new transport connection"); + } finally { + if (channel != null) { + channel.shutdownNow(); + } + server.shutdownNow(); + if (channel != null) { + assertTrue(channel.awaitTermination(10, TimeUnit.SECONDS)); + } + assertTrue(server.awaitTermination(10, TimeUnit.SECONDS)); + } + } + + @Test + void callerSuppliedChannelsRemainCallerOwnedAndDoNotAcquireTokensAtBuildTime() { + ResourceIdTest.RecordingCredential credential = new ResourceIdTest.RecordingCredential(); + ManagedChannel suppliedChannel = mock(ManagedChannel.class); + try (ResourceIdTest.ChannelCapture channels = new ResourceIdTest.ChannelCapture(); + DurableTaskClient client = DurableTaskSchedulerClientExtensions + .createClientBuilder("https://scheduler.example", "test-hub", credential, "api://Custom") + .grpcChannel(suppliedChannel).build(); + DurableTaskGrpcWorker worker = DurableTaskSchedulerWorkerExtensions + .createWorkerBuilder("https://scheduler.example", "test-hub", credential, "api://Custom") + .grpcChannel(suppliedChannel).build()) { + assertTrue(credential.scopes.isEmpty()); + } + verify(suppliedChannel, never()).shutdown(); + verify(suppliedChannel, never()).shutdownNow(); + assertTrue(credential.scopes.isEmpty()); + } + + private static void call(ManagedChannel channel) { + ClientCalls.blockingUnaryCall(channel, ResourceIdTest.METHOD, + CallOptions.DEFAULT.withDeadlineAfter(10, TimeUnit.SECONDS), Empty.getDefaultInstance()); + } +} diff --git a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ConnectionStringAuthenticationTest.java b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ConnectionStringAuthenticationTest.java new file mode 100644 index 00000000..efc9d675 --- /dev/null +++ b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ConnectionStringAuthenticationTest.java @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask.azuremanaged; + +import com.azure.core.credential.TokenCredential; +import com.azure.identity.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.util.Collections; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ConnectionStringAuthenticationTest { + private static final String GOVERNMENT = "https://durabletask.azure.us"; + private static final String AUTHORITY = "https://login.microsoftonline.us/"; + private static final String CONNECTION = "Endpoint=https://scheduler.example;TaskHub=test-hub;Authentication="; + + static Stream authenticationTypes() { + return Stream.of( + Arguments.of("DefaultAzure", DefaultAzureCredentialBuilder.class, DefaultAzureCredential.class), + Arguments.of("ManagedIdentity", ManagedIdentityCredentialBuilder.class, ManagedIdentityCredential.class), + Arguments.of("WorkloadIdentity", WorkloadIdentityCredentialBuilder.class, WorkloadIdentityCredential.class), + Arguments.of("Environment", EnvironmentCredentialBuilder.class, EnvironmentCredential.class), + Arguments.of("AzureCli", AzureCliCredentialBuilder.class, AzureCliCredential.class), + Arguments.of("AzurePowerShell", AzurePowerShellCredentialBuilder.class, AzurePowerShellCredential.class), + Arguments.of("VisualStudioCode", VisualStudioCodeCredentialBuilder.class, VisualStudioCodeCredential.class), + Arguments.of("IntelliJ", IntelliJCredentialBuilder.class, IntelliJCredential.class), + Arguments.of("InteractiveBrowser", InteractiveBrowserCredentialBuilder.class, InteractiveBrowserCredential.class) + ); + } + + @ParameterizedTest + @MethodSource("authenticationTypes") + void everyCredentialTypeReceivesDefaultAndExplicitAudiences( + String authentication, Class builderType, Class credentialType) { + for (String resourceId : new String[] {null, "api://Custom/.default/.DEFAULT/"}) { + ResourceIdTest.RecordingCredential recording = new ResourceIdTest.RecordingCredential(); + TokenCredential credential = mock(credentialType); + when(credential.getToken(any())).thenAnswer(call -> recording.getToken(call.getArgument(0))); + try (MockedStatic defaults = mockStatic(ResourceId.class, CALLS_REAL_METHODS); + MockedConstruction builders = mockConstruction(builderType, + withSettings().defaultAnswer(call -> { + if (call.getMethod().getName().equals("build")) { + return credential; + } + return RETURNS_SELF.answer(call); + })); + ResourceIdTest.ChannelCapture channels = new ResourceIdTest.ChannelCapture()) { + defaults.when(ResourceId::getDefault).thenReturn(GOVERNMENT); + String connection = CONNECTION + authentication + + (resourceId == null ? "" : ";ResourceId=" + resourceId); + DurableTaskSchedulerClientOptions.fromConnectionString(connection).createGrpcChannel(); + DurableTaskSchedulerWorkerOptions.fromConnectionString(connection).createGrpcChannel(); + assertEquals(2, builders.constructed().size()); + assertTrue(recording.scopes.isEmpty()); + channels.start(0); + channels.start(0); + channels.start(1); + channels.start(1); + String expected = resourceId == null ? GOVERNMENT + "/.default" : "api://Custom/.default/.default"; + assertEquals(Collections.nCopies(3, expected), recording.scopes); + } + } + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {AUTHORITY, " https://login.microsoftonline.us/ "}) + void authorityIsForwardedOnlyWhenExplicitlyConfigured(String authority) { + try (MockedConstruction defaults = + mockConstruction(DefaultAzureCredentialBuilder.class); + MockedConstruction environments = + mockConstruction(EnvironmentCredentialBuilder.class); + MockedConstruction workloads = + mockConstruction(WorkloadIdentityCredentialBuilder.class); + MockedConstruction browsers = + mockConstruction(InteractiveBrowserCredentialBuilder.class)) { + for (String authentication : new String[] { + "DefaultAzure", "Environment", "WorkloadIdentity", "InteractiveBrowser"}) { + DurableTaskSchedulerConnectionString connection = new DurableTaskSchedulerConnectionString( + CONNECTION + authentication + ";ResourceId=" + GOVERNMENT + + (authority == null ? "" : ";AuthorityHost=" + authority)); + connection.getCredential(); + assertEquals(GOVERNMENT, connection.getResourceId()); + assertEquals("https://scheduler.example", connection.getEndpoint()); + assertEquals(authority == null ? null : authority.trim(), connection.getAuthorityHost()); + } + if (authority == null || authority.isEmpty()) { + verify(defaults.constructed().get(0), never()).authorityHost(anyString()); + verify(environments.constructed().get(0), never()).authorityHost(anyString()); + verify(workloads.constructed().get(0), never()).authorityHost(anyString()); + verify(browsers.constructed().get(0), never()).authorityHost(anyString()); + } else { + verify(defaults.constructed().get(0)).authorityHost(AUTHORITY); + verify(environments.constructed().get(0)).authorityHost(AUTHORITY); + verify(workloads.constructed().get(0)).authorityHost(AUTHORITY); + verify(browsers.constructed().get(0)).authorityHost(AUTHORITY); + } + } + } + + @ParameterizedTest + @MethodSource("authenticationTypes") + void regionAndAudienceDoNotConfigureCredentialAuthority( + String authentication, Class builderType, Class credentialType) { + try (MockedStatic defaults = mockStatic(ResourceId.class, CALLS_REAL_METHODS); + MockedConstruction builders = mockConstruction(builderType, + withSettings().defaultAnswer(RETURNS_SELF))) { + defaults.when(ResourceId::getDefault).thenReturn(GOVERNMENT); + new DurableTaskSchedulerConnectionString(CONNECTION + authentication).getCredential(); + new DurableTaskSchedulerConnectionString( + CONNECTION + authentication + ";ResourceId=api://CustomAudience").getCredential(); + assertEquals(2, builders.constructed().size()); + for (T builder : builders.constructed()) { + assertTrue(mockingDetails(builder).getInvocations().stream() + .noneMatch(call -> call.getMethod().getName().equals("authorityHost"))); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"ManagedIdentity", "AzureCli", "AzurePowerShell", "VisualStudioCode", "IntelliJ", "None"}) + void authorityDoesNotOverrideManagedIdentityOrDeveloperToolClouds(String authentication) { + // These credentials have no authorityHost API. Creating them must not interpret this as an endpoint. + DurableTaskSchedulerConnectionString connection = new DurableTaskSchedulerConnectionString( + CONNECTION + authentication + ";AuthorityHost=" + AUTHORITY + ";ResourceId=" + GOVERNMENT); + TokenCredential credential = connection.getCredential(); + assertEquals("https://scheduler.example", connection.getEndpoint()); + if (authentication.equals("None")) { + assertNull(credential); + } else { + assertNotNull(credential); + } + } +} diff --git a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerOptionsTest.java b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerOptionsTest.java index 5fab6912..d4569ea9 100644 --- a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerOptionsTest.java +++ b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/DurableTaskSchedulerWorkerOptionsTest.java @@ -36,7 +36,7 @@ public void defaultConstructor_SetsDefaultValues() { assertEquals("", options.getEndpointAddress()); assertEquals("", options.getTaskHubName()); assertNull(options.getCredential()); - assertEquals("https://durabletask.io", options.getResourceId()); + assertEquals(ResourceId.getDefault(), options.getResourceId()); assertFalse(options.isAllowInsecureCredentials()); assertEquals(Duration.ofMinutes(5), options.getTokenRefreshMargin()); } diff --git a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ResourceIdTest.java b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ResourceIdTest.java new file mode 100644 index 00000000..e281fadb --- /dev/null +++ b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ResourceIdTest.java @@ -0,0 +1,361 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.durabletask.azuremanaged; + +import com.azure.core.credential.AccessToken; +import com.azure.core.credential.TokenCredential; +import com.azure.core.credential.TokenRequestContext; +import com.azure.identity.DefaultAzureCredential; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.google.protobuf.Empty; +import com.microsoft.durabletask.DurableTaskGrpcClientBuilder; +import com.microsoft.durabletask.DurableTaskGrpcWorkerBuilder; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ChannelCredentials; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Grpc; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.protobuf.ProtoUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import reactor.core.publisher.Mono; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ResourceIdTest { + private static final String PUBLIC = "https://durabletask.io"; + private static final String GOVERNMENT = "https://durabletask.azure.us"; + private static final String ENDPOINT = "https://scheduler.example:443"; + private static final String HUB = "test-hub"; + private static final Metadata.Key AUTHORIZATION = + Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER); + static final MethodDescriptor METHOD = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("test.Service/Call") + .setRequestMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .setResponseMarshaller(ProtoUtils.marshaller(Empty.getDefaultInstance())) + .build(); + + private enum Path { + CLIENT_OPTIONS, WORKER_OPTIONS, CLIENT_CREATE, WORKER_CREATE, CLIENT_USE, WORKER_USE, + CLIENT_CONNECTION_OPTIONS, WORKER_CONNECTION_OPTIONS, + CLIENT_CONNECTION_CREATE, WORKER_CONNECTION_CREATE, CLIENT_CONNECTION_USE, WORKER_CONNECTION_USE + } + + static Stream audiences() { + return Stream.of( + Arguments.of(null, null, PUBLIC), + Arguments.of("", null, PUBLIC), + Arguments.of("westus2", null, PUBLIC), + Arguments.of("chinaeast2", null, PUBLIC), + Arguments.of("notusgov", null, PUBLIC), + Arguments.of("notusdod", null, PUBLIC), + Arguments.of(" usgovvirginia", null, PUBLIC), + Arguments.of("usgovvirginia", null, GOVERNMENT), + Arguments.of("USGOVARIZONA", null, GOVERNMENT), + Arguments.of("UsGovTexas", null, GOVERNMENT), + Arguments.of("usdodcentral", null, GOVERNMENT), + Arguments.of("USDODEAST", null, GOVERNMENT), + Arguments.of("UsDodCentral", null, GOVERNMENT), + Arguments.of("usgov", null, GOVERNMENT), + Arguments.of("usdod", null, GOVERNMENT), + Arguments.of(null, "", PUBLIC), + Arguments.of("usgovvirginia", "", GOVERNMENT), + Arguments.of("usdodcentral", "", GOVERNMENT), + Arguments.of("usgovvirginia", PUBLIC, PUBLIC), + Arguments.of("usdodcentral", PUBLIC, PUBLIC), + Arguments.of("westus2", GOVERNMENT, GOVERNMENT), + Arguments.of("chinaeast2", "https://durabletask.example", "https://durabletask.example"), + Arguments.of(null, GOVERNMENT + "/", GOVERNMENT), + Arguments.of(null, GOVERNMENT + "/.default", GOVERNMENT), + Arguments.of(null, GOVERNMENT + "//.DEFAULT//", GOVERNMENT), + Arguments.of(null, " \t" + GOVERNMENT + "/.default/ \t", GOVERNMENT), + Arguments.of("usgovvirginia", "api://CustomAudience/resource/.DEFAULT/", + "api://CustomAudience/resource"), + Arguments.of("westus2", "api://custom/.default/.default", "api://custom/.default") + ); + } + + @ParameterizedTest + @MethodSource("audiences") + void allPublicPathsRequestSelectedScopeAndRetainItOnRefresh( + String region, String resourceId, String expected) { + String defaultAudience = ResourceId.getDefault(region); + try (MockedStatic defaults = defaultsFor(region)) { + for (Path path : Path.values()) { + RecordingCredential recording = new RecordingCredential(); + DefaultAzureCredential credential = mock(DefaultAzureCredential.class); + when(credential.getToken(any())).thenAnswer(call -> recording.getToken(call.getArgument(0))); + try (MockedConstruction credentials = + mockConstruction(DefaultAzureCredentialBuilder.class, + (builder, context) -> when(builder.build()).thenReturn(credential)); + ChannelCapture channels = new ChannelCapture()) { + configure(path, resourceId, credential); + assertTrue(recording.scopes.isEmpty(), "Construction must not acquire a token: " + path); + assertEquals(1, channels.interceptors.size(), path.toString()); + assertEquals(Collections.singletonList("scheduler.example:443"), channels.authorities); + + assertEquals("Bearer token-1", channels.start(0).get(AUTHORIZATION)); + // The first token is expired. Refresh must keep the scope even if the region changes. + defaults.when(ResourceId::getDefault).thenReturn(expected.equals(PUBLIC) ? GOVERNMENT : PUBLIC); + assertEquals("Bearer token-2", channels.start(0).get(AUTHORIZATION)); + assertEquals("Bearer token-2", channels.start(0).get(AUTHORIZATION)); + assertEquals(Arrays.asList(expected + "/.default", expected + "/.default"), + recording.scopes, path.toString()); + defaults.when(ResourceId::getDefault).thenReturn(defaultAudience); + } + } + } + } + + @ParameterizedTest + @ValueSource(strings = {" \t ", "///", "/.default", "/.DEFAULT///", " /.DEFAULT/// "}) + void allPublicPathsRejectInvalidAudiencesEvenWithoutCredentials(String resourceId) { + try (ChannelCapture channels = new ChannelCapture()) { + for (Path path : Path.values()) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> configure(path, resourceId, null), path.toString()); + assertTrue(error.getMessage().contains("ResourceId")); + assertTrue(error.getMessage().contains("token audience URI")); + } + assertTrue(channels.interceptors.isEmpty(), "Invalid audiences must fail before channel creation"); + } + } + + @Test + void anonymousAuthenticationStaysAnonymousOnEveryPath() { + try (ChannelCapture channels = new ChannelCapture()) { + for (Path path : Path.values()) { + configure(path, GOVERNMENT, null); + assertNull(channels.start(channels.interceptors.size() - 1).get(AUTHORIZATION), path.toString()); + } + } + } + + @ParameterizedTest + @ValueSource(strings = {"westus2", "UsGovVirginia", "USDODEAST"}) + void existingConvenienceOverloadsUseRegionDefault(String region) { + try (MockedStatic defaults = defaultsFor(region); + ChannelCapture channels = new ChannelCapture()) { + RecordingCredential credential = new RecordingCredential(); + DurableTaskSchedulerClientExtensions.createClientBuilder(ENDPOINT, HUB, credential); + DurableTaskSchedulerWorkerExtensions.createWorkerBuilder(ENDPOINT, HUB, credential); + DurableTaskSchedulerClientExtensions.useDurableTaskScheduler( + new DurableTaskGrpcClientBuilder(), ENDPOINT, HUB, credential); + DurableTaskSchedulerWorkerExtensions.useDurableTaskScheduler( + new DurableTaskGrpcWorkerBuilder(), ENDPOINT, HUB, credential); + assertTrue(credential.scopes.isEmpty()); + for (int i = 0; i < 4; i++) { + channels.start(i); + } + assertEquals(Collections.nCopies(4, ResourceId.getDefault(region) + "/.default"), credential.scopes); + } + } + + @Test + void defaultsArePerInstanceAndRetainedAcrossChannelRecreationAndConnectionStringConversion() { + try (MockedStatic defaults = defaultsFor("usgovvirginia"); + ChannelCapture channels = new ChannelCapture()) { + RecordingCredential credential = new RecordingCredential(); + DurableTaskSchedulerClientOptions governmentClient = clientOptions(credential); + DurableTaskSchedulerWorkerOptions governmentWorker = workerOptions(credential); + DurableTaskSchedulerConnectionString governmentConnection = + new DurableTaskSchedulerConnectionString(connectionString(null, false)); + + defaults.when(ResourceId::getDefault).thenReturn(PUBLIC); + DurableTaskSchedulerClientOptions publicClient = clientOptions(credential); + DurableTaskSchedulerWorkerOptions publicWorker = workerOptions(credential); + DurableTaskSchedulerConnectionString publicConnection = + new DurableTaskSchedulerConnectionString(connectionString(null, false)); + + defaults.when(ResourceId::getDefault).thenReturn(GOVERNMENT); + for (int i = 0; i < 2; i++) { + governmentClient.createGrpcChannel(); + governmentWorker.createGrpcChannel(); + DurableTaskSchedulerClientOptions.fromConnectionString(governmentConnection) + .setCredential(credential).createGrpcChannel(); + DurableTaskSchedulerWorkerOptions.fromConnectionString(governmentConnection) + .setCredential(credential).createGrpcChannel(); + publicClient.createGrpcChannel(); + publicWorker.createGrpcChannel(); + DurableTaskSchedulerClientOptions.fromConnectionString(publicConnection) + .setCredential(credential).createGrpcChannel(); + DurableTaskSchedulerWorkerOptions.fromConnectionString(publicConnection) + .setCredential(credential).createGrpcChannel(); + } + assertTrue(credential.scopes.isEmpty()); + for (int i = 0; i < channels.interceptors.size(); i++) { + channels.start(i); + assertEquals((i % 8 < 4 ? GOVERNMENT : PUBLIC) + "/.default", credential.scopes.get(i)); + } + } + } + + @Test + void defaultReadsActualEnvironment() { + String region = System.getenv("REGION_NAME"); + String expected = region != null && region.matches("(?i)^(usgov|usdod).*") ? GOVERNMENT : PUBLIC; + try (ChannelCapture channels = new ChannelCapture()) { + RecordingCredential credential = new RecordingCredential(); + clientOptions(credential).createGrpcChannel(); + workerOptions(credential).createGrpcChannel(); + DurableTaskSchedulerClientOptions.fromConnectionString(connectionString(null, false)) + .setCredential(credential).createGrpcChannel(); + DurableTaskSchedulerWorkerOptions.fromConnectionString(connectionString(null, false)) + .setCredential(credential).createGrpcChannel(); + for (int i = 0; i < 4; i++) { + channels.start(i); + } + assertEquals(Collections.nCopies(4, expected + "/.default"), credential.scopes); + } + } + + private static MockedStatic defaultsFor(String region) { + String value = ResourceId.getDefault(region); + MockedStatic defaults = mockStatic(ResourceId.class, CALLS_REAL_METHODS); + defaults.when(ResourceId::getDefault).thenReturn(value); + return defaults; + } + + private static DurableTaskSchedulerClientOptions clientOptions(TokenCredential credential) { + return new DurableTaskSchedulerClientOptions() + .setEndpointAddress(ENDPOINT).setTaskHubName(HUB).setCredential(credential); + } + + private static DurableTaskSchedulerWorkerOptions workerOptions(TokenCredential credential) { + return new DurableTaskSchedulerWorkerOptions() + .setEndpointAddress(ENDPOINT).setTaskHubName(HUB).setCredential(credential); + } + + private static String connectionString(String resourceId, boolean authenticated) { + return "Endpoint=" + ENDPOINT + ";TaskHub=" + HUB + + ";Authentication=" + (authenticated ? "DefaultAzure" : "None") + + (resourceId == null ? "" : ";ResourceId=" + resourceId); + } + + private static void configure(Path path, String resourceId, TokenCredential credential) { + String connection = connectionString(resourceId, credential != null); + switch (path) { + case CLIENT_OPTIONS: + clientOptions(credential).setResourceId(resourceId).createGrpcChannel(); + break; + case WORKER_OPTIONS: + workerOptions(credential).setResourceId(resourceId).createGrpcChannel(); + break; + case CLIENT_CREATE: + DurableTaskSchedulerClientExtensions.createClientBuilder(ENDPOINT, HUB, credential, resourceId); + break; + case WORKER_CREATE: + DurableTaskSchedulerWorkerExtensions.createWorkerBuilder(ENDPOINT, HUB, credential, resourceId); + break; + case CLIENT_USE: + DurableTaskSchedulerClientExtensions.useDurableTaskScheduler( + new DurableTaskGrpcClientBuilder(), ENDPOINT, HUB, credential, resourceId); + break; + case WORKER_USE: + DurableTaskSchedulerWorkerExtensions.useDurableTaskScheduler( + new DurableTaskGrpcWorkerBuilder(), ENDPOINT, HUB, credential, resourceId); + break; + case CLIENT_CONNECTION_OPTIONS: + DurableTaskSchedulerClientOptions.fromConnectionString(connection).createGrpcChannel(); + break; + case WORKER_CONNECTION_OPTIONS: + DurableTaskSchedulerWorkerOptions.fromConnectionString(connection).createGrpcChannel(); + break; + case CLIENT_CONNECTION_CREATE: + DurableTaskSchedulerClientExtensions.createClientBuilder(connection); + break; + case WORKER_CONNECTION_CREATE: + DurableTaskSchedulerWorkerExtensions.createWorkerBuilder(connection); + break; + case CLIENT_CONNECTION_USE: + DurableTaskSchedulerClientExtensions.useDurableTaskScheduler( + new DurableTaskGrpcClientBuilder(), connection); + break; + case WORKER_CONNECTION_USE: + DurableTaskSchedulerWorkerExtensions.useDurableTaskScheduler( + new DurableTaskGrpcWorkerBuilder(), connection); + break; + default: + throw new AssertionError(path); + } + } + + static final class RecordingCredential implements TokenCredential { + final List scopes = new ArrayList<>(); + + @Override + public Mono getToken(TokenRequestContext context) { + assertEquals(1, context.getScopes().size()); + scopes.add(context.getScopes().get(0)); + OffsetDateTime expiration = scopes.size() == 1 + ? OffsetDateTime.now().minusMinutes(1) : OffsetDateTime.now().plusHours(1); + return Mono.just(new AccessToken("token-" + scopes.size(), expiration)); + } + } + + // Capture real authentication interceptors without connecting to a cloud endpoint. + static final class ChannelCapture implements AutoCloseable { + final List interceptors = new ArrayList<>(); + final List authorities = new ArrayList<>(); + private final MockedStatic grpc = mockStatic(Grpc.class); + + ChannelCapture() { + ManagedChannelBuilder builder = mock(ManagedChannelBuilder.class); + doAnswer(call -> { + interceptors.add(call.getArgument(0)); + return builder; + }).when(builder).intercept(any(ClientInterceptor.class)); + when(builder.build()).thenReturn(mock(ManagedChannel.class)); + grpc.when(() -> Grpc.newChannelBuilder(anyString(), any(ChannelCredentials.class))) + .thenAnswer(call -> { + authorities.add(call.getArgument(0)); + return builder; + }); + } + + Metadata start(int index) { + Channel transport = mock(Channel.class); + ClientCall call = new ClientCall() { + @Override public void start(Listener listener, Metadata headers) { } + @Override public void request(int count) { } + @Override public void cancel(String message, Throwable cause) { } + @Override public void halfClose() { } + @Override public void sendMessage(Empty message) { } + }; + when(transport.newCall(METHOD, CallOptions.DEFAULT)).thenReturn(call); + ClientCall intercepted = + interceptors.get(index).interceptCall(METHOD, CallOptions.DEFAULT, transport); + Metadata headers = new Metadata(); + intercepted.start(new ClientCall.Listener() { }, headers); + assertEquals(HUB, headers.get(Metadata.Key.of("taskhub", Metadata.ASCII_STRING_MARSHALLER))); + assertNotNull(headers.get(Metadata.Key.of("x-user-agent", Metadata.ASCII_STRING_MARSHALLER))); + return headers; + } + + @Override + public void close() { + grpc.close(); + } + } +} From e35b693978bc698a5d604ba7a5525b476916d768 Mon Sep 17 00:00:00 2001 From: Bernd Verst Date: Mon, 28 Sep 2026 11:01:35 -0700 Subject: [PATCH 2/2] Address token audience test review feedback Remove the unused authority-test parameter and project its argument source to the two required fields. Explicitly allow insecure credentials for the worker loopback transport test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../durabletask/azuremanaged/AudienceTransportTest.java | 3 ++- .../azuremanaged/ConnectionStringAuthenticationTest.java | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/AudienceTransportTest.java b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/AudienceTransportTest.java index 9c1c7618..a9b36fb8 100644 --- a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/AudienceTransportTest.java +++ b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/AudienceTransportTest.java @@ -66,7 +66,8 @@ public ServerCall.Listener interceptCall( String endpoint = "http://127.0.0.1:" + server.getPort(); channel = (ManagedChannel) (worker ? new DurableTaskSchedulerWorkerOptions().setEndpointAddress(endpoint) - .setTaskHubName("test-hub").setCredential(credential).createGrpcChannel() + .setAllowInsecureCredentials(true).setTaskHubName("test-hub") + .setCredential(credential).createGrpcChannel() : new DurableTaskSchedulerClientOptions().setEndpointAddress(endpoint) .setAllowInsecureCredentials(true).setTaskHubName("test-hub") .setCredential(credential).createGrpcChannel()); diff --git a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ConnectionStringAuthenticationTest.java b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ConnectionStringAuthenticationTest.java index efc9d675..02faf28c 100644 --- a/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ConnectionStringAuthenticationTest.java +++ b/azuremanaged/src/test/java/com/microsoft/durabletask/azuremanaged/ConnectionStringAuthenticationTest.java @@ -39,6 +39,11 @@ static Stream authenticationTypes() { ); } + static Stream authenticationBuilders() { + return authenticationTypes().map(arguments -> + Arguments.of(arguments.get()[0], arguments.get()[1])); + } + @ParameterizedTest @MethodSource("authenticationTypes") void everyCredentialTypeReceivesDefaultAndExplicitAudiences( @@ -110,9 +115,9 @@ void authorityIsForwardedOnlyWhenExplicitlyConfigured(String authority) { } @ParameterizedTest - @MethodSource("authenticationTypes") + @MethodSource("authenticationBuilders") void regionAndAudienceDoNotConfigureCredentialAuthority( - String authentication, Class builderType, Class credentialType) { + String authentication, Class builderType) { try (MockedStatic defaults = mockStatic(ResourceId.class, CALLS_REAL_METHODS); MockedConstruction builders = mockConstruction(builderType, withSettings().defaultAnswer(RETURNS_SELF))) {