From dddaf87b2fa10c3c846ba352da30b75faf4ce595 Mon Sep 17 00:00:00 2001 From: Bernd Verst Date: Fri, 25 Sep 2026 23:59:43 -0700 Subject: [PATCH 1/2] feat(azuremanaged): configure token audiences and credential authority Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- examples/azure-managed/README.md | 30 ++ .../durabletask-js-azuremanaged/CHANGELOG.md | 13 + .../durabletask-js-azuremanaged/README.md | 92 +++++- .../src/client-builder.ts | 19 +- .../src/connection-string.ts | 25 +- .../src/credential-factory.ts | 11 +- .../src/options.ts | 43 ++- .../src/worker-builder.ts | 19 +- .../test/unit/connection-string.spec.ts | 16 + .../test/unit/credential-factory.spec.ts | 155 +++++++++ .../test/unit/options.spec.ts | 9 + .../test/unit/resource-id-reconnect.spec.ts | 162 ++++++++++ .../test/unit/resource-id.spec.ts | 295 ++++++++++++++++++ 13 files changed, 863 insertions(+), 26 deletions(-) create mode 100644 packages/durabletask-js-azuremanaged/test/unit/credential-factory.spec.ts create mode 100644 packages/durabletask-js-azuremanaged/test/unit/resource-id-reconnect.spec.ts create mode 100644 packages/durabletask-js-azuremanaged/test/unit/resource-id.spec.ts diff --git a/examples/azure-managed/README.md b/examples/azure-managed/README.md index 6cc1ddfa..28cfedc8 100644 --- a/examples/azure-managed/README.md +++ b/examples/azure-managed/README.md @@ -96,6 +96,36 @@ npm run example -- ./examples/azure-managed/hello-orchestrations/index.ts See each sample's README for details. See [Feature Coverage Map](#feature-coverage-map) below for full feature mapping. +### Azure Government Configuration + +Use the actual endpoint and task hub of your government scheduler. For example, the existing samples can use +this connection string in `.env`: + +```env +DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://.usgovvirginia.durabletask.azure.us;Authentication=DefaultAzure;TaskHub=;ResourceId=https://durabletask.azure.us;AuthorityHost=https://login.microsoftonline.us +``` + +For local Azure CLI authentication, select the tool's cloud separately before signing in: + +```bash +az cloud set --name AzureUSGovernment +az login +npm run example -- ./examples/azure-managed/hello-orchestrations/index.ts +``` + +`ResourceId` is the token audience URI, not the scheduler's ARM resource path. `AuthorityHost` configures +supported SDK-created Azure Identity credentials; it does not select the Azure CLI or PowerShell cloud. +Managed identity instead uses its hosting environment's identity endpoint and does not use an authority override. +When creating your own credential, set `authorityHost` on that credential, not on a token request. +Omitting the authority preserves Azure Identity defaults, including `AZURE_AUTHORITY_HOST` where applicable. + +If `ResourceId` is omitted or empty, `REGION_NAME` beginning with `usgov` or `usdod` (case-insensitive) selects +`https://durabletask.azure.us`; all other values select `https://durabletask.io`. This is an intentional +government-region default change. Pin `ResourceId=https://durabletask.io` to retain public-cloud authentication +in a government-region environment. Audience selection never changes the endpoint or credential authority. +See the [authentication API reference](../../packages/durabletask-js-azuremanaged/README.md#token-audience-and-azure-government) +for explicit-parameter examples and normalization rules. + ### CI Validation Samples are validated automatically by [`.github/workflows/validate-samples.yaml`](../../.github/workflows/validate-samples.yaml). Any subfolder with a `sample.json` is auto-discovered and tested on every PR. diff --git a/packages/durabletask-js-azuremanaged/CHANGELOG.md b/packages/durabletask-js-azuremanaged/CHANGELOG.md index 1f763ee4..2017ef9e 100644 --- a/packages/durabletask-js-azuremanaged/CHANGELOG.md +++ b/packages/durabletask-js-azuremanaged/CHANGELOG.md @@ -2,6 +2,13 @@ ### New +- Support the `ResourceId` token audience URI in connection strings and an optional final `resourceId` + argument in the client/worker factory functions, alongside existing builder/options setters. + Trim surrounding whitespace and trailing slashes, remove one case-insensitive `/.default` suffix, + and reject nonempty values that normalize to empty. Preserve custom URI casing. +- Support optional connection-string `AuthorityHost` for SDK-created Azure Identity credentials that + support authority configuration. Omission preserves Azure Identity defaults/environment settings. + Caller-supplied credentials own their authority; managed identity and developer-tool clouds remain separate. - Add an optional final version argument to all orchestrator/activity builder registrations, preserving same-name versions and version-aware auto filters in the built worker. - Add `DurableTaskAzureManagedWorkerBuilder.silentDisconnectTimeout()` to configure the @@ -12,6 +19,12 @@ ### Breaking changes +- Missing, null, or empty resource IDs now default to `https://durabletask.azure.us` when `REGION_NAME` + starts with `usgov` or `usdod` (case-insensitive), otherwise `https://durabletask.io`. Defaults are + captured per options instance and preserved across token refreshes and worker reconnects. + Set `ResourceId=https://durabletask.io` (or `.resourceId("https://durabletask.io")`) explicitly to + retain the public audience in a government-region environment. Audience selection does not change + the service endpoint or credential authority/cloud. - Schemeless endpoints now default to HTTPS. Connection-string and builder `.endpoint(...)` users must prefix plaintext local or emulator endpoints with `http://`. diff --git a/packages/durabletask-js-azuremanaged/README.md b/packages/durabletask-js-azuremanaged/README.md index 03453481..08fe6b13 100644 --- a/packages/durabletask-js-azuremanaged/README.md +++ b/packages/durabletask-js-azuremanaged/README.md @@ -53,6 +53,81 @@ const worker = createAzureManagedWorkerBuilder("https://myservice.durabletask.io await worker.start(); ``` +### Token Audience and Azure Government + +The optional `resourceId` is a **token audience URI**, not an Azure Resource Manager resource path. +Configure it with `.resourceId(value)` on either builder, `.setResourceId(value)` on either options class, +the optional fourth argument to the explicit-parameter factory functions, or `ResourceId` in a connection string. +Existing calls remain valid. + +| Configuration | Selected resource ID | +| --- | --- | +| Explicit nonempty `resourceId` / `ResourceId` | The normalized explicit value | +| Missing, `null`, or empty, and `REGION_NAME` starts with `usgov` or `usdod` (case-insensitive) | `https://durabletask.azure.us` | +| Otherwise | `https://durabletask.io` | + +The default is captured when the connection options are created, including when a builder is constructed or +its `.connectionString(...)` replaces the options. It stays fixed across token refreshes, worker reconnects, +and restarts. Resetting the resource ID to `null`, `undefined`, or `""` uses that captured default. +An explicit public audience overrides a government region, and vice versa. `chinaeast2`, `notusgov`, and +`notusdod` use the public default. The SDK does not infer an audience from the endpoint. + +**Intentional default change:** applications running with a government/DoD `REGION_NAME` now request the +government audience rather than the public audience. Set `resourceId` explicitly to `https://durabletask.io` +if such an application intentionally connects to the public service. + +Explicit values have surrounding whitespace and trailing `/` characters trimmed, then **one** +case-insensitive `/.default` suffix removed, followed by any remaining trailing `/` characters. +The SDK requests `/.default` without changing the URI's casing: + +| Input | Requested scope | +| --- | --- | +| `https://durabletask.azure.us/` | `https://durabletask.azure.us/.default` | +| `https://durabletask.azure.us//.DEFAULT//` | `https://durabletask.azure.us/.default` | +| `api://CustomAudience/resource/.DEFAULT/` | `api://CustomAudience/resource/.default` | + +Whitespace-only values, `///`, `/.default`, and `/.DEFAULT///` throw a configuration `Error`, +even with anonymous authentication. `ResourceId=` selects the default, but `ResourceId= ` is invalid. + +The **audience, credential authority/cloud, and service endpoint are separate settings**. Neither `resourceId` +nor `REGION_NAME` changes the endpoint or authority. Configure authority on a caller-supplied Azure Identity +credential when constructing it; `TokenCredential.getToken()` does not accept a per-request authority override: + +```typescript +import { AzureAuthorityHosts, DefaultAzureCredential } from "@azure/identity"; +import { createAzureManagedClient, createAzureManagedWorkerBuilder } from "@microsoft/durabletask-js-azuremanaged"; + +const credential = new DefaultAzureCredential({ + authorityHost: AzureAuthorityHosts.AzureGovernment, +}); +const endpoint = "https://myaccount.usgovvirginia.durabletask.azure.us"; +const resourceId = "https://durabletask.azure.us"; + +const client = createAzureManagedClient(endpoint, "myTaskHub", credential, resourceId); +const worker = createAzureManagedWorkerBuilder(endpoint, "myTaskHub", credential) + .resourceId(resourceId) + .addOrchestrator(myOrchestrator) + .addActivity(myActivity) + .build(); +``` + +For SDK-created credentials, use the optional connection-string `AuthorityHost` property: + +```text +Endpoint=https://myaccount.usgovvirginia.durabletask.azure.us;Authentication=DefaultAzure;TaskHub=myTaskHub;ResourceId=https://durabletask.azure.us;AuthorityHost=https://login.microsoftonline.us +``` + +`AuthorityHost` is forwarded to `DefaultAzure`, `WorkloadIdentity`, `Environment`, `VisualStudioCode`, and +`InteractiveBrowser` credentials. Omission (or an empty value) preserves Azure Identity's defaults, including +`AZURE_AUTHORITY_HOST` where applicable; the SDK does not substitute its own authority default. +Managed identity uses the hosting environment's identity endpoint, so an Entra authority override does not apply. +`AzureCli` and `AzurePowerShell` use their tools' cloud configuration rather than `AuthorityHost`; configure those +tools separately, including when used through `DefaultAzureCredential`. VS Code also needs its extension/account +configured for the target cloud. + +See the [government-cloud sample configuration](../../examples/azure-managed/README.md#azure-government-configuration) +for running the existing samples against a government scheduler. + ### Versioned registrations All four orchestrator/activity registration methods accept an optional final `version` argument, @@ -92,9 +167,12 @@ The connection string `Authentication` parameter supports the following values: ## Connection String Format ``` -Endpoint=;Authentication=;TaskHub=[;ClientID=][;TenantId=] +Endpoint=;Authentication=;TaskHub=[;ClientID=][;TenantId=][;ResourceId=][;AuthorityHost=] ``` +Property names are case-insensitive. `ResourceId` normalization and `AuthorityHost` support are described above. +For workload identity, `TokenFilePath` and comma-separated `AdditionallyAllowedTenants` are also supported. + ## Transport Security Endpoint transport and authentication are configured independently: @@ -133,11 +211,19 @@ new DurableTaskAzureManagedClientBuilder() ### Functions - `createAzureManagedClient(connectionString)` - Create a client from connection string -- `createAzureManagedClient(endpoint, taskHubName, credential)` - Create a client with explicit parameters +- `createAzureManagedClient(endpoint, taskHubName, credential?, resourceId?)` - Create a client with explicit parameters - `createAzureManagedWorkerBuilder(connectionString)` - Create a worker builder from connection string -- `createAzureManagedWorkerBuilder(endpoint, taskHubName, credential)` - Create a worker builder with explicit parameters +- `createAzureManagedWorkerBuilder(endpoint, taskHubName, credential?, resourceId?)` - Create a worker builder with explicit parameters - `getCredentialFromAuthenticationType(connectionString)` - Get credential from connection string auth type +### Audience Configuration + +- Client and worker builders: `.resourceId(resourceId?: string | null)` +- Client and worker options: `.setResourceId(resourceId?: string | null)` and `.getResourceId()` (normalized) +- Parsed connection strings: `.getResourceId()` (raw) and `.getAuthorityHost()` + +Configure connection options after `.connectionString(...)`, which replaces previous audience settings. + ## License MIT diff --git a/packages/durabletask-js-azuremanaged/src/client-builder.ts b/packages/durabletask-js-azuremanaged/src/client-builder.ts index 5eda8190..eab641be 100644 --- a/packages/durabletask-js-azuremanaged/src/client-builder.ts +++ b/packages/durabletask-js-azuremanaged/src/client-builder.ts @@ -71,12 +71,17 @@ export class DurableTaskAzureManagedClientBuilder { } /** - * Sets the resource ID for authentication. + * Sets the token audience URI for authentication, not an Azure Resource Manager resource path. + * Normalizes whitespace, trailing slashes and one /.default suffix. + * Does not change the endpoint or credential authority. * - * @param resourceId The resource ID. + * @param resourceId The audience URI. Null, undefined or empty uses the default captured when + * the connection options were created: https://durabletask.azure.us for REGION_NAME starting + * with usgov/usdod (case-insensitive), otherwise https://durabletask.io. * @returns This builder instance. + * @throws Error if a nonempty value becomes empty after normalization. */ - resourceId(resourceId: string): DurableTaskAzureManagedClientBuilder { + resourceId(resourceId?: string | null): DurableTaskAzureManagedClientBuilder { this._options.setResourceId(resourceId); return this; } @@ -185,6 +190,8 @@ export function createAzureManagedClient(connectionString: string): TaskHubGrpcC * @param endpoint The endpoint address for Azure-managed Durable Task service. * @param taskHubName The name of the task hub to connect to. * @param credential The token credential for authentication, or null for anonymous access. + * @param resourceId Optional token audience URI. Uses the per-instance REGION_NAME default when omitted or empty. + * Configure authority on the supplied credential, independently of this audience and the endpoint. * @returns A new configured TaskHubGrpcClient instance. * @throws Error if endpoint or taskHubName is null or undefined. */ @@ -192,18 +199,20 @@ export function createAzureManagedClient( endpoint: string, taskHubName: string, credential?: TokenCredential | null, + resourceId?: string | null, ): TaskHubGrpcClient; export function createAzureManagedClient( endpointOrConnectionString: string, taskHubName?: string, credential?: TokenCredential | null, + resourceId?: string | null, ): TaskHubGrpcClient { const builder = new DurableTaskAzureManagedClientBuilder(); if (taskHubName !== undefined) { - // Called with (endpoint, taskHubName, credential?) - return builder.endpoint(endpointOrConnectionString, taskHubName, credential).build(); + // Called with (endpoint, taskHubName, credential?, resourceId?) + return builder.endpoint(endpointOrConnectionString, taskHubName, credential).resourceId(resourceId).build(); } else { // Called with (connectionString) return builder.connectionString(endpointOrConnectionString).build(); diff --git a/packages/durabletask-js-azuremanaged/src/connection-string.ts b/packages/durabletask-js-azuremanaged/src/connection-string.ts index f40af05f..b0aab08f 100644 --- a/packages/durabletask-js-azuremanaged/src/connection-string.ts +++ b/packages/durabletask-js-azuremanaged/src/connection-string.ts @@ -33,6 +33,24 @@ export class DurableTaskAzureManagedConnectionString { return this.getRequiredValue("Authentication"); } + /** + * Gets the raw token audience URI. Options normalize this value before use. + * Missing or empty selects the per-options REGION_NAME default; whitespace-only is invalid. + * This is not an Azure Resource Manager resource path. + */ + getResourceId(): string | undefined { + return this.getValue("ResourceId"); + } + + /** + * Gets the optional Azure Identity authority host for SDK-created credentials that support it. + * Omission preserves Azure Identity defaults, including AZURE_AUTHORITY_HOST where applicable. + * Does not apply to managed identity or configure developer tools' clouds. + */ + getAuthorityHost(): string | undefined { + return this.getValue("AuthorityHost"); + } + /** * Gets the managed identity or workload identity client ID specified in the connection string. * @returns The client ID, or undefined if not specified. @@ -109,9 +127,10 @@ export class DurableTaskAzureManagedConnectionString { for (const pair of pairs) { const equalsIndex = pair.indexOf("="); if (equalsIndex > 0) { - const key = pair.substring(0, equalsIndex).trim(); - const value = pair.substring(equalsIndex + 1).trim(); - properties.set(key.toLowerCase(), value); + const key = pair.substring(0, equalsIndex).trim().toLowerCase(); + const value = pair.substring(equalsIndex + 1); + // Preserve ResourceId whitespace so options can distinguish empty from whitespace-only input. + properties.set(key, key === "resourceid" ? value : value.trim()); } } diff --git a/packages/durabletask-js-azuremanaged/src/credential-factory.ts b/packages/durabletask-js-azuremanaged/src/credential-factory.ts index fa8880e0..d79b03b3 100644 --- a/packages/durabletask-js-azuremanaged/src/credential-factory.ts +++ b/packages/durabletask-js-azuremanaged/src/credential-factory.ts @@ -25,10 +25,12 @@ export function getCredentialFromAuthenticationType( connectionString: DurableTaskAzureManagedConnectionString, ): TokenCredential | null { const authType = connectionString.getAuthentication().toLowerCase().trim(); + const authorityHost = connectionString.getAuthorityHost(); + const authorityOptions = authorityHost ? { authorityHost } : undefined; switch (authType) { case "defaultazure": - return new DefaultAzureCredential(); + return new DefaultAzureCredential(authorityOptions); case "managedidentity": { const clientId = connectionString.getClientId(); @@ -45,6 +47,7 @@ export function getCredentialFromAuthenticationType( const additionallyAllowedTenants = connectionString.getAdditionallyAllowedTenants(); return new WorkloadIdentityCredential({ + ...authorityOptions, ...(clientId && { clientId }), ...(tenantId && { tenantId }), ...(tokenFilePath && { tokenFilePath }), @@ -53,7 +56,7 @@ export function getCredentialFromAuthenticationType( } case "environment": - return new EnvironmentCredential(); + return new EnvironmentCredential(authorityOptions); case "azurecli": return new AzureCliCredential(); @@ -62,10 +65,10 @@ export function getCredentialFromAuthenticationType( return new AzurePowerShellCredential(); case "visualstudiocode": - return new VisualStudioCodeCredential(); + return new VisualStudioCodeCredential(authorityOptions); case "interactivebrowser": - return new InteractiveBrowserCredential({}); + return new InteractiveBrowserCredential(authorityOptions ?? {}); case "none": return null; diff --git a/packages/durabletask-js-azuremanaged/src/options.ts b/packages/durabletask-js-azuremanaged/src/options.ts index 67ecb876..c49d9172 100644 --- a/packages/durabletask-js-azuremanaged/src/options.ts +++ b/packages/durabletask-js-azuremanaged/src/options.ts @@ -14,10 +14,13 @@ import { ClientRetryOptions, createServiceConfig, DEFAULT_SERVICE_CONFIG } from * Contains properties common to both client and worker configurations. */ abstract class DurableTaskAzureManagedOptionsBase { + private readonly _defaultResourceId: string = /^(usgov|usdod)/i.test(process.env.REGION_NAME ?? "") + ? "https://durabletask.azure.us" + : "https://durabletask.io"; protected _endpointAddress: string = ""; protected _taskHubName: string = ""; protected _credential: TokenCredential | null = null; - protected _resourceId: string = "https://durabletask.io"; + protected _resourceId: string = this._defaultResourceId; protected _allowInsecureCredentials: boolean = false; protected _tokenRefreshMargin: number = 5 * 60 * 1000; // 5 minutes in milliseconds protected _retryOptions: ClientRetryOptions | undefined = undefined; @@ -44,12 +47,27 @@ abstract class DurableTaskAzureManagedOptionsBase { } /** - * Gets the resource ID. + * Gets the normalized token audience URI (not an Azure Resource Manager resource path). + * This does not configure the endpoint or credential authority. */ getResourceId(): string { return this._resourceId; } + protected resolveResourceId(resourceId?: string | null): string { + if (resourceId === undefined || resourceId === null || resourceId === "") { + return this._defaultResourceId; + } + + const normalized = resourceId.trim().replace(/\/+$/, "").replace(/\/\.default$/i, "").replace(/\/+$/, ""); + if (normalized === "") { + throw new Error( + "resourceId (ResourceId) cannot be empty after normalization. Provide a token audience URI, or omit it to use the region default.", + ); + } + return normalized; + } + /** * Gets whether insecure credentials are allowed. */ @@ -194,6 +212,7 @@ abstract class DurableTaskAzureManagedOptionsBase { protected configureFromConnectionString(connectionString: DurableTaskAzureManagedConnectionString): void { this._endpointAddress = connectionString.getEndpoint(); this._taskHubName = connectionString.getTaskHubName(); + this._resourceId = this.resolveResourceId(connectionString.getResourceId()); this._credential = getCredentialFromAuthenticationType(connectionString); } @@ -244,8 +263,14 @@ export class DurableTaskAzureManagedClientOptions extends DurableTaskAzureManage return this; } - setResourceId(resourceId: string): DurableTaskAzureManagedClientOptions { - this._resourceId = resourceId; + /** + * Sets the token audience URI, trimming whitespace, trailing slashes and one /.default suffix. + * Null, undefined or empty selects the REGION_NAME default captured when these options were created: + * https://durabletask.azure.us for usgov/usdod prefixes (case-insensitive), otherwise https://durabletask.io. + * @throws Error if a nonempty value becomes empty after normalization. + */ + setResourceId(resourceId?: string | null): DurableTaskAzureManagedClientOptions { + this._resourceId = this.resolveResourceId(resourceId); return this; } @@ -352,8 +377,14 @@ export class DurableTaskAzureManagedWorkerOptions extends DurableTaskAzureManage return this; } - setResourceId(resourceId: string): DurableTaskAzureManagedWorkerOptions { - this._resourceId = resourceId; + /** + * Sets the token audience URI, trimming whitespace, trailing slashes and one /.default suffix. + * Null, undefined or empty selects the REGION_NAME default captured when these options were created: + * https://durabletask.azure.us for usgov/usdod prefixes (case-insensitive), otherwise https://durabletask.io. + * @throws Error if a nonempty value becomes empty after normalization. + */ + setResourceId(resourceId?: string | null): DurableTaskAzureManagedWorkerOptions { + this._resourceId = this.resolveResourceId(resourceId); return this; } diff --git a/packages/durabletask-js-azuremanaged/src/worker-builder.ts b/packages/durabletask-js-azuremanaged/src/worker-builder.ts index 6b646bcb..8a0b1a76 100644 --- a/packages/durabletask-js-azuremanaged/src/worker-builder.ts +++ b/packages/durabletask-js-azuremanaged/src/worker-builder.ts @@ -89,12 +89,17 @@ export class DurableTaskAzureManagedWorkerBuilder { } /** - * Sets the resource ID for authentication. + * Sets the token audience URI for authentication, not an Azure Resource Manager resource path. + * Normalizes whitespace, trailing slashes and one /.default suffix. + * Does not change the endpoint or credential authority. * - * @param resourceId The resource ID. + * @param resourceId The audience URI. Null, undefined or empty uses the default captured when + * the connection options were created: https://durabletask.azure.us for REGION_NAME starting + * with usgov/usdod (case-insensitive), otherwise https://durabletask.io. * @returns This builder instance. + * @throws Error if a nonempty value becomes empty after normalization. */ - resourceId(resourceId: string): DurableTaskAzureManagedWorkerBuilder { + resourceId(resourceId?: string | null): DurableTaskAzureManagedWorkerBuilder { this._options.setResourceId(resourceId); return this; } @@ -384,6 +389,8 @@ export function createAzureManagedWorkerBuilder(connectionString: string): Durab * @param endpoint The endpoint address for Azure-managed Durable Task service. * @param taskHubName The name of the task hub to connect to. * @param credential The token credential for authentication, or null for anonymous access. + * @param resourceId Optional token audience URI. Uses the per-instance REGION_NAME default when omitted or empty. + * Configure authority on the supplied credential, independently of this audience and the endpoint. * @returns A new DurableTaskAzureManagedWorkerBuilder instance. * @throws Error if endpoint or taskHubName is null or undefined. */ @@ -391,18 +398,20 @@ export function createAzureManagedWorkerBuilder( endpoint: string, taskHubName: string, credential?: TokenCredential | null, + resourceId?: string | null, ): DurableTaskAzureManagedWorkerBuilder; export function createAzureManagedWorkerBuilder( endpointOrConnectionString: string, taskHubName?: string, credential?: TokenCredential | null, + resourceId?: string | null, ): DurableTaskAzureManagedWorkerBuilder { const builder = new DurableTaskAzureManagedWorkerBuilder(); if (taskHubName !== undefined) { - // Called with (endpoint, taskHubName, credential?) - return builder.endpoint(endpointOrConnectionString, taskHubName, credential); + // Called with (endpoint, taskHubName, credential?, resourceId?) + return builder.endpoint(endpointOrConnectionString, taskHubName, credential).resourceId(resourceId); } else { // Called with (connectionString) return builder.connectionString(endpointOrConnectionString); diff --git a/packages/durabletask-js-azuremanaged/test/unit/connection-string.spec.ts b/packages/durabletask-js-azuremanaged/test/unit/connection-string.spec.ts index 7bd44170..8f85e1c2 100644 --- a/packages/durabletask-js-azuremanaged/test/unit/connection-string.spec.ts +++ b/packages/durabletask-js-azuremanaged/test/unit/connection-string.spec.ts @@ -178,6 +178,22 @@ describe("DurableTaskAzureManagedConnectionString", () => { expect(connectionString.getClientId()).toBe("my-client-id"); }); + describe("getResourceId", () => { + it("returns undefined when absent and preserves empty versus whitespace-only values", () => { + expect(new DurableTaskAzureManagedConnectionString(VALID_CONNECTION_STRING).getResourceId()).toBeUndefined(); + expect(new DurableTaskAzureManagedConnectionString(VALID_CONNECTION_STRING + ";ResourceId=").getResourceId()) + .toBe(""); + expect(new DurableTaskAzureManagedConnectionString(VALID_CONNECTION_STRING + ";ResourceId= \t ").getResourceId()) + .toBe(" \t "); + }); + + it("preserves the raw audience for normalization by options", () => { + const raw = " api://Custom/.default/.DEFAULT/ "; + const parsed = new DurableTaskAzureManagedConnectionString(VALID_CONNECTION_STRING + ";RESOURCEID=" + raw); + expect(parsed.getResourceId()).toBe(raw); + }); + }); + it("should return undefined when not present", () => { const connectionString = new DurableTaskAzureManagedConnectionString(VALID_CONNECTION_STRING); diff --git a/packages/durabletask-js-azuremanaged/test/unit/credential-factory.spec.ts b/packages/durabletask-js-azuremanaged/test/unit/credential-factory.spec.ts new file mode 100644 index 00000000..e3d28701 --- /dev/null +++ b/packages/durabletask-js-azuremanaged/test/unit/credential-factory.spec.ts @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as identity from "@azure/identity"; +import { + DurableTaskAzureManagedClientOptions, + DurableTaskAzureManagedWorkerOptions, + DurableTaskAzureManagedConnectionString, + getCredentialFromAuthenticationType, +} from "../../src"; + +jest.mock("@azure/identity", () => { + const credentialConstructor = () => + jest.fn(() => ({ + getToken: jest.fn(async () => ({ token: "recorded-token", expiresOnTimestamp: Date.now() + 3_600_000 })), + })); + return { + DefaultAzureCredential: credentialConstructor(), + ManagedIdentityCredential: credentialConstructor(), + WorkloadIdentityCredential: credentialConstructor(), + EnvironmentCredential: credentialConstructor(), + AzureCliCredential: credentialConstructor(), + AzurePowerShellCredential: credentialConstructor(), + VisualStudioCodeCredential: credentialConstructor(), + InteractiveBrowserCredential: credentialConstructor(), + }; +}); + +const credentialCases = [ + { authentication: "DefaultAzure", Credential: identity.DefaultAzureCredential, supportsAuthority: true }, + { authentication: "ManagedIdentity", Credential: identity.ManagedIdentityCredential, supportsAuthority: false }, + { authentication: "WorkloadIdentity", Credential: identity.WorkloadIdentityCredential, supportsAuthority: true }, + { authentication: "Environment", Credential: identity.EnvironmentCredential, supportsAuthority: true }, + { authentication: "AzureCli", Credential: identity.AzureCliCredential, supportsAuthority: false }, + { authentication: "AzurePowerShell", Credential: identity.AzurePowerShellCredential, supportsAuthority: false }, + { authentication: "VisualStudioCode", Credential: identity.VisualStudioCodeCredential, supportsAuthority: true }, + { authentication: "InteractiveBrowser", Credential: identity.InteractiveBrowserCredential, supportsAuthority: true }, +]; +const AUTHORITY = "https://login.microsoftonline.us"; +const ENDPOINT = "https://scheduler.example"; + +function connectionString(authentication: string, extra = ""): string { + return `Endpoint=${ENDPOINT};TaskHub=test;Authentication=${authentication}${extra}`; +} + +describe("SDK-created credentials", () => { + const originalRegion = process.env.REGION_NAME; + const originalAuthority = process.env.AZURE_AUTHORITY_HOST; + + beforeEach(() => { + process.env.REGION_NAME = "UsGovVirginia"; + process.env.AZURE_AUTHORITY_HOST = AUTHORITY; + jest.clearAllMocks(); + }); + + afterAll(() => { + if (originalRegion === undefined) delete process.env.REGION_NAME; + else process.env.REGION_NAME = originalRegion; + if (originalAuthority === undefined) delete process.env.AZURE_AUTHORITY_HOST; + else process.env.AZURE_AUTHORITY_HOST = originalAuthority; + }); + + describe.each(credentialCases)("$authentication", ({ authentication, Credential, supportsAuthority }) => { + it("forwards AuthorityHost only to credentials that support it", () => { + const parsed = new DurableTaskAzureManagedConnectionString( + connectionString(authentication, `;aUtHoRiTyHoSt= ${AUTHORITY} `), + ); + const credential = getCredentialFromAuthenticationType(parsed); + const args = jest.mocked(Credential).mock.calls[0]; + expect(args[0]).toEqual(supportsAuthority ? { authorityHost: AUTHORITY } : undefined); + expect(credential?.getToken).not.toHaveBeenCalled(); + }); + + it.each(["", ";AuthorityHost="])("leaves omitted authority to Azure Identity, not REGION_NAME (%s)", (extra) => { + getCredentialFromAuthenticationType( + new DurableTaskAzureManagedConnectionString(connectionString(authentication, extra)), + ); + const args = jest.mocked(Credential).mock.calls[0]; + expect(args[0] ?? {}).not.toHaveProperty("authorityHost"); + }); + + it.each([DurableTaskAzureManagedClientOptions, DurableTaskAzureManagedWorkerOptions])( + "%s requests a normalized custom audience independently of authority", + async (Options) => { + const options = Options.fromConnectionString( + connectionString(authentication, `;ResourceId= api://Custom/.default/.DEFAULT/ ;AuthorityHost=${AUTHORITY}`), + ); + const credential = options.getCredential(); + expect(credential).not.toBeNull(); + expect(options.getEndpointAddress()).toBe(ENDPOINT); + expect(credential?.getToken).not.toHaveBeenCalled(); + await options.createMetadataGenerator()(); + expect(credential?.getToken).toHaveBeenCalledWith("api://Custom/.default/.default", undefined); + }, + ); + + it.each([DurableTaskAzureManagedClientOptions, DurableTaskAzureManagedWorkerOptions])( + "%s requests the government default without changing authority", + async (Options) => { + const options = Options.fromConnectionString(connectionString(authentication)); + await options.createMetadataGenerator()(); + expect(options.getCredential()?.getToken).toHaveBeenCalledWith( + "https://durabletask.azure.us/.default", + undefined, + ); + expect(jest.mocked(Credential).mock.calls[0][0] ?? {}).not.toHaveProperty("authorityHost"); + }, + ); + }); + + it("preserves workload identity configuration alongside authority", () => { + getCredentialFromAuthenticationType( + new DurableTaskAzureManagedConnectionString( + connectionString( + "WorkloadIdentity", + `;ClientID=client;TenantId=tenant;TokenFilePath=token-file;AdditionallyAllowedTenants=one, two;AuthorityHost=${AUTHORITY}`, + ), + ), + ); + expect(identity.WorkloadIdentityCredential).toHaveBeenCalledWith({ + clientId: "client", + tenantId: "tenant", + tokenFilePath: "token-file", + additionallyAllowedTenants: ["one", "two"], + authorityHost: AUTHORITY, + }); + }); + + it("preserves managed identity client ID without passing an authority", () => { + getCredentialFromAuthenticationType( + new DurableTaskAzureManagedConnectionString( + connectionString("ManagedIdentity", `;ClientID=client;AuthorityHost=${AUTHORITY}`), + ), + ); + expect(identity.ManagedIdentityCredential).toHaveBeenCalledWith({ clientId: "client" }); + }); + + it("does not create credentials for anonymous authentication", async () => { + const options = DurableTaskAzureManagedClientOptions.fromConnectionString( + connectionString("None", `;ResourceId=api://Custom;AuthorityHost=${AUTHORITY}`), + ); + expect(options.getCredential()).toBeNull(); + expect((await options.createMetadataGenerator()()).get("authorization")).toEqual([]); + for (const { Credential } of credentialCases) { + expect(Credential).not.toHaveBeenCalled(); + } + }); + + it("rejects invalid audiences before creating a credential", () => { + expect(() => + DurableTaskAzureManagedClientOptions.fromConnectionString(connectionString("DefaultAzure", ";ResourceId= \t ")), + ).toThrow(/resourceId.*cannot be empty after normalization/); + expect(identity.DefaultAzureCredential).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/durabletask-js-azuremanaged/test/unit/options.spec.ts b/packages/durabletask-js-azuremanaged/test/unit/options.spec.ts index 90b20b6f..9f5744e2 100644 --- a/packages/durabletask-js-azuremanaged/test/unit/options.spec.ts +++ b/packages/durabletask-js-azuremanaged/test/unit/options.spec.ts @@ -21,6 +21,15 @@ describe("Options", () => { const CUSTOM_RESOURCE_ID = "https://custom.resource"; const CUSTOM_REFRESH_MARGIN = 10 * 60 * 1000; // 10 minutes in ms + const originalRegion = process.env.REGION_NAME; + beforeEach(() => { + delete process.env.REGION_NAME; + }); + afterEach(() => { + if (originalRegion === undefined) delete process.env.REGION_NAME; + else process.env.REGION_NAME = originalRegion; + }); + describe("DurableTaskAzureManagedClientOptions", () => { describe("fromConnectionString", () => { it("should create valid options from connection string", () => { diff --git a/packages/durabletask-js-azuremanaged/test/unit/resource-id-reconnect.spec.ts b/packages/durabletask-js-azuremanaged/test/unit/resource-id-reconnect.spec.ts new file mode 100644 index 00000000..4f1f78aa --- /dev/null +++ b/packages/durabletask-js-azuremanaged/test/unit/resource-id-reconnect.spec.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { DefaultAzureCredential, TokenCredential } from "@azure/identity"; +import * as grpc from "@grpc/grpc-js"; +import { EventEmitter } from "events"; +import { Empty } from "google-protobuf/google/protobuf/empty_pb"; +import { PassThrough } from "stream"; +import { NoOpLogger } from "@microsoft/durabletask-js"; +import { GrpcClient } from "../../../durabletask-js/src/client/client-grpc"; +import { WorkItem } from "../../../durabletask-js/src/proto/orchestrator_service_pb"; +import { createAzureManagedWorkerBuilder, DurableTaskAzureManagedWorkerBuilder } from "../../src"; + +const ENDPOINT = "https://scheduler.example:8443"; +const workerFactories = [ + { + name: "builder endpoint", + create: (credential: TokenCredential, resourceId?: string) => + new DurableTaskAzureManagedWorkerBuilder().endpoint(ENDPOINT, "test-hub", credential).resourceId(resourceId), + }, + { + name: "factory endpoint", + create: (credential: TokenCredential, resourceId?: string) => + createAzureManagedWorkerBuilder(ENDPOINT, "test-hub", credential, resourceId), + }, + { + name: "builder connection string", + create: (_credential: TokenCredential, resourceId?: string) => + new DurableTaskAzureManagedWorkerBuilder().connectionString( + `Endpoint=${ENDPOINT};TaskHub=test-hub;Authentication=DefaultAzure` + + (resourceId === undefined ? "" : `;ResourceId=${resourceId}`), + ), + }, + { + name: "factory connection string", + create: (_credential: TokenCredential, resourceId?: string) => + createAzureManagedWorkerBuilder( + `Endpoint=${ENDPOINT};TaskHub=test-hub;Authentication=DefaultAzure` + + (resourceId === undefined ? "" : `;ResourceId=${resourceId}`), + ), + }, +]; + +describe.each(workerFactories)("$name audience lifetime", ({ create }) => { + const originalRegion = process.env.REGION_NAME; + + beforeEach(() => { + process.env.REGION_NAME = "UsDodCentral"; + jest.useFakeTimers(); + jest.spyOn(Math, "random").mockReturnValue(0); + }); + + afterEach(() => { + if (originalRegion === undefined) delete process.env.REGION_NAME; + else process.env.REGION_NAME = originalRegion; + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it.each([ + [undefined, "https://durabletask.azure.us"], + [" https://durabletask.io/.DEFAULT/ ", "https://durabletask.io"], + ["api://Custom/.default/.default", "api://Custom/.default"], + ])("preserves %s across refresh, reconnect, channel recreation and restart", async (resourceId, expected) => { + const credential = { + getToken: jest.fn(async (_scope: string | string[]) => ({ + token: "recorded-token", + expiresOnTimestamp: Date.now() + 3_600_000, + })), + } satisfies TokenCredential; + jest.spyOn(DefaultAzureCredential.prototype, "getToken").mockImplementation(credential.getToken); + const streams: PassThrough[] = []; + const metadataSeen: grpc.Metadata[] = []; + const credentialsSeen: (grpc.ChannelCredentials | undefined)[] = []; + const targetsSeen: string[] = []; + const originalGenerateClient = GrpcClient.prototype._generateClient; + const channelOptions = jest.spyOn(GrpcClient.prototype, "_generateChannelOptions"); + const generateClient = jest.spyOn(GrpcClient.prototype, "_generateClient").mockImplementation(function ( + this: GrpcClient, + ) { + const stub = originalGenerateClient.call(this); + credentialsSeen.push(this["_credentials"]); + targetsSeen.push(stub.getChannel().getTarget()); + jest.spyOn(stub, "hello").mockImplementation((_request, metadata, _options, callback) => { + metadataSeen.push(metadata); + callback(null, new Empty()); + return Object.assign(new EventEmitter(), { + cancel: jest.fn(), + getPeer: () => "test", + getAuthContext: () => null, + }); + }); + jest.spyOn(stub, "getWorkItems").mockImplementation((_request, metadata) => { + if (metadata) metadataSeen.push(metadata); + const stream = Object.assign(new PassThrough({ objectMode: true }), { + cancel(this: PassThrough) { + this.emit("close"); + }, + getPeer: () => "test", + getAuthContext: () => null, + deserialize: WorkItem.deserializeBinary, + }); + streams.push(stream); + return stream; + }); + return stub; + }); + const worker = create(credential, resourceId) + .logger(new NoOpLogger()) + .workerId("test-worker") + .silentDisconnectTimeout(0) + .channelRecreateFailureThreshold(2) + .grpcChannelOptions({ "grpc.keepalive_time_ms": 1234 }) + .build(); + expect(credential.getToken).not.toHaveBeenCalled(); + + async function stopWorker(): Promise { + const stopping = worker.stop(); + await jest.advanceTimersByTimeAsync(1000); + await stopping; + } + + try { + await worker.start(); + await jest.advanceTimersByTimeAsync(0); + expect(streams).toHaveLength(1); + expect(credential.getToken).toHaveBeenCalledTimes(1); + process.env.REGION_NAME = "westus2"; + + for (let i = 0; i < 2; i++) { + jest.setSystemTime(Date.now() + 3_300_001); + streams[i].emit("error", Object.assign(new Error("test unavailable"), { code: grpc.status.UNAVAILABLE })); + await jest.advanceTimersByTimeAsync(1); + expect(streams).toHaveLength(i + 2); + expect(credential.getToken).toHaveBeenCalledTimes(i + 2); + } + expect(generateClient).toHaveBeenCalledTimes(2); + + await stopWorker(); + jest.setSystemTime(Date.now() + 3_300_001); + await worker.start(); + await jest.advanceTimersByTimeAsync(0); + expect(streams).toHaveLength(4); + expect(credential.getToken.mock.calls).toEqual(Array(4).fill([`${expected}/.default`, undefined])); + expect(metadataSeen).toHaveLength(8); // Hello and getWorkItems on each connection. + for (const metadata of metadataSeen) { + expect(metadata.get("authorization")).toEqual(["Bearer recorded-token"]); + expect(metadata.get("taskhub")).toEqual(["test-hub"]); + expect(metadata.get("workerid")).toEqual(["test-worker"]); + } + expect(targetsSeen).toEqual(Array(3).fill("dns:scheduler.example:8443")); + expect(credentialsSeen[0]).toBeDefined(); + expect(credentialsSeen.every((value) => value === credentialsSeen[0])).toBe(true); + for (const [options] of channelOptions.mock.calls) { + expect(options).toMatchObject({ "grpc.keepalive_time_ms": 1234 }); + } + } finally { + await stopWorker(); + } + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/durabletask-js-azuremanaged/test/unit/resource-id.spec.ts b/packages/durabletask-js-azuremanaged/test/unit/resource-id.spec.ts new file mode 100644 index 00000000..092cfa62 --- /dev/null +++ b/packages/durabletask-js-azuremanaged/test/unit/resource-id.spec.ts @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { DefaultAzureCredential, TokenCredential } from "@azure/identity"; +import * as grpc from "@grpc/grpc-js"; +import { TaskHubGrpcClient, TaskHubGrpcWorker } from "@microsoft/durabletask-js"; +import { + createAzureManagedClient, + createAzureManagedWorkerBuilder, + DurableTaskAzureManagedClientBuilder, + DurableTaskAzureManagedClientOptions, + DurableTaskAzureManagedConnectionString, + DurableTaskAzureManagedWorkerBuilder, + DurableTaskAzureManagedWorkerOptions, +} from "../../src"; + +const PUBLIC = "https://durabletask.io"; +const GOVERNMENT = "https://durabletask.azure.us"; +const ENDPOINT = "https://scheduler.example:8443"; +const TASKHUB = "test-hub"; +type MetadataGenerator = () => Promise; +type ResourceId = string | null | undefined; + +const resourceCases: [string | undefined, ResourceId, string][] = [ + [undefined, undefined, PUBLIC], + ["", undefined, PUBLIC], + ["westus2", null, PUBLIC], + ["chinaeast2", null, PUBLIC], + ["notusgov", null, PUBLIC], + ["notusdod", null, PUBLIC], + [" usgovvirginia", null, PUBLIC], + ["usgovvirginia", undefined, GOVERNMENT], + ["USGOVARIZONA", null, GOVERNMENT], + ["UsGovTexas", null, GOVERNMENT], + ["usdodcentral", null, GOVERNMENT], + ["USDODEAST", null, GOVERNMENT], + ["UsDodCentral", null, GOVERNMENT], + ["usgov", null, GOVERNMENT], + ["usdod", null, GOVERNMENT], + [undefined, "", PUBLIC], + ["usgovvirginia", "", GOVERNMENT], + ["usdodcentral", "", GOVERNMENT], + ["usgovvirginia", PUBLIC, PUBLIC], + ["usdodcentral", PUBLIC, PUBLIC], + ["westus2", GOVERNMENT, GOVERNMENT], + ["chinaeast2", "https://durabletask.example", "https://durabletask.example"], + [undefined, GOVERNMENT + "/", GOVERNMENT], + [undefined, GOVERNMENT + "/.default", GOVERNMENT], + [undefined, GOVERNMENT + "//.DEFAULT//", GOVERNMENT], + [undefined, " \t" + GOVERNMENT + "/.default/ \t", GOVERNMENT], + ["usgovvirginia", "api://CustomAudience/resource/.DEFAULT/", "api://CustomAudience/resource"], + ["westus2", "api://custom/.default/.default", "api://custom/.default"], +]; +const invalidResourceIds = [" \t ", "///", "/.default", "/.DEFAULT///", " /.DEFAULT/// "]; + +function setRegion(region: string | undefined): void { + if (region === undefined) { + delete process.env.REGION_NAME; + } else { + process.env.REGION_NAME = region; + } +} + +function connectionString(resourceId: ResourceId, credential: TokenCredential | null): string { + return ( + `Endpoint=${ENDPOINT};TaskHub=${TASKHUB};Authentication=${credential ? "DefaultAzure" : "None"}` + + (resourceId == null ? "" : `;rEsOuRcEiD=${resourceId}`) + ); +} + +const built: (TaskHubGrpcClient | TaskHubGrpcWorker)[] = []; + +function metadataFrom(value: TaskHubGrpcClient | TaskHubGrpcWorker): MetadataGenerator { + built.push(value); + const generator = value instanceof TaskHubGrpcClient ? value["_metadataGenerator"] : value["_metadataGenerator"]; + if (!generator) { + throw new Error("Builder did not forward the metadata generator"); + } + return generator; +} + +const paths: { + name: string; + create(credential: TokenCredential | null, resourceId: ResourceId): MetadataGenerator; +}[] = []; + +for (const Options of [DurableTaskAzureManagedClientOptions, DurableTaskAzureManagedWorkerOptions]) { + paths.push( + { + name: `${Options.name} setters`, + create: (credential, resourceId) => + new Options() + .setEndpointAddress(ENDPOINT) + .setTaskHubName(TASKHUB) + .setCredential(credential) + .setResourceId(resourceId) + .createMetadataGenerator(), + }, + { + name: `${Options.name} connection string`, + create: (credential, resourceId) => + Options.fromConnectionString(connectionString(resourceId, credential)).createMetadataGenerator(), + }, + { + name: `${Options.name} parsed connection string`, + create: (credential, resourceId) => + Options.fromParsedConnectionString( + new DurableTaskAzureManagedConnectionString(connectionString(resourceId, credential)), + ).createMetadataGenerator(), + }, + ); +} + +for (const Builder of [DurableTaskAzureManagedClientBuilder, DurableTaskAzureManagedWorkerBuilder]) { + paths.push( + { + name: `${Builder.name} endpoint`, + create: (credential, resourceId) => + metadataFrom(new Builder().endpoint(ENDPOINT, TASKHUB, credential).resourceId(resourceId).build()), + }, + { + name: `${Builder.name} connection string`, + create: (credential, resourceId) => + metadataFrom(new Builder().connectionString(connectionString(resourceId, credential)).build()), + }, + ); +} + +paths.push( + { + name: "createAzureManagedClient endpoint", + create: (credential, resourceId) => + metadataFrom(createAzureManagedClient(ENDPOINT, TASKHUB, credential, resourceId)), + }, + { + name: "createAzureManagedClient connection string", + create: (credential, resourceId) => + metadataFrom(createAzureManagedClient(connectionString(resourceId, credential))), + }, + { + name: "createAzureManagedWorkerBuilder endpoint", + create: (credential, resourceId) => + metadataFrom(createAzureManagedWorkerBuilder(ENDPOINT, TASKHUB, credential, resourceId).build()), + }, + { + name: "createAzureManagedWorkerBuilder connection string", + create: (credential, resourceId) => + metadataFrom(createAzureManagedWorkerBuilder(connectionString(resourceId, credential)).build()), + }, +); + +describe("Resource audience", () => { + const originalRegion = process.env.REGION_NAME; + + beforeEach(() => { + delete process.env.REGION_NAME; + jest.useFakeTimers(); + }); + + afterEach(() => { + for (const value of built) { + if (value instanceof TaskHubGrpcClient) { + value["_stub"].close(); + } else { + value["_stub"]?.close(); + } + } + built.length = 0; + setRegion(originalRegion); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function recordingCredential() { + const credential = { + getToken: jest.fn(async (_scopes: string | string[]) => ({ + token: "recorded-token", + expiresOnTimestamp: Date.now() + 3_600_000, + })), + } satisfies TokenCredential; + jest.spyOn(DefaultAzureCredential.prototype, "getToken").mockImplementation(credential.getToken); + return credential; + } + + describe.each(paths)("$name", ({ create }) => { + it.each(resourceCases)( + "requests the correct scope and refreshes it: region=%s resourceId=%s", + async (region, resourceId, expected) => { + setRegion(region); + const credential = recordingCredential(); + const generate = create(credential, resourceId); + expect(credential.getToken).not.toHaveBeenCalled(); + setRegion(region?.toLowerCase().startsWith("usgov") ? "westus2" : "usgovvirginia"); + + const metadata = await Promise.all(Array.from({ length: 5 }, () => generate())); + await generate(); + expect(credential.getToken.mock.calls).toEqual([[`${expected}/.default`, undefined]]); + expect(metadata[0].get("authorization")).toEqual(["Bearer recorded-token"]); + expect(metadata[0].get("taskhub")).toEqual([TASKHUB]); + + // Enter the existing five-minute refresh margin without expiring the token. + jest.setSystemTime(Date.now() + 3_300_001); + await Promise.all(Array.from({ length: 5 }, () => generate())); + expect(credential.getToken.mock.calls).toEqual([ + [`${expected}/.default`, undefined], + [`${expected}/.default`, undefined], + ]); + }, + ); + + it.each(invalidResourceIds)("rejects invalid anonymous resourceId %j", (resourceId) => { + expect(() => create(null, resourceId)).toThrow(/resourceId.*cannot be empty after normalization/); + }); + + it("resolves defaults independently for each instance", async () => { + const credential = recordingCredential(); + setRegion("usgovvirginia"); + const government = create(credential, undefined); + setRegion("westus2"); + const publicCloud = create(credential, undefined); + setRegion("usdodeast"); + await government(); + await publicCloud(); + expect(credential.getToken.mock.calls).toEqual([ + [`${GOVERNMENT}/.default`, undefined], + [`${PUBLIC}/.default`, undefined], + ]); + }); + + it("keeps anonymous authentication anonymous", async () => { + const credential = recordingCredential(); + setRegion("usgovvirginia"); + const metadata = await create(null, "api://Custom/.default")(); + expect(metadata.get("authorization")).toEqual([]); + expect(metadata.get("taskhub")).toEqual([TASKHUB]); + expect(credential.getToken).not.toHaveBeenCalled(); + }); + }); + + describe.each([DurableTaskAzureManagedClientOptions, DurableTaskAzureManagedWorkerOptions])( + "%s configuration lifetime", + (Options) => { + it.each([undefined, null, ""])("pins the default when resetting resourceId to %s", async (resourceId) => { + setRegion("usgovvirginia"); + const credential = recordingCredential(); + const options = new Options().setEndpointAddress(ENDPOINT).setCredential(credential); + options.setResourceId("api://custom"); + setRegion("westus2"); + options.setResourceId(resourceId); + await options.createMetadataGenerator()(); + expect(credential.getToken).toHaveBeenCalledWith(`${GOVERNMENT}/.default`, undefined); + }); + + it("does not infer the audience from a government endpoint or change the supplied credential", async () => { + const credential = recordingCredential(); + const endpoint = "https://account.usgovvirginia.durabletask.azure.us"; + const options = new Options().setEndpointAddress(endpoint).setCredential(credential); + expect(options.getCredential()).toBe(credential); + expect(options.getEndpointAddress()).toBe(endpoint); + expect(options.getHostAddress()).toBe("account.usgovvirginia.durabletask.azure.us"); + await options.createMetadataGenerator()(); + expect(credential.getToken).toHaveBeenCalledWith(`${PUBLIC}/.default`, undefined); + }); + }, + ); + + it.each([DurableTaskAzureManagedClientBuilder, DurableTaskAzureManagedWorkerBuilder])( + "%s captures defaults at options creation, not build or token acquisition", + async (Builder) => { + setRegion("usgovvirginia"); + const credential = recordingCredential(); + const builder = new Builder().endpoint(ENDPOINT, TASKHUB, credential); + setRegion("westus2"); + await metadataFrom(builder.build())(); + expect(credential.getToken).toHaveBeenCalledWith(`${GOVERNMENT}/.default`, undefined); + }, + ); + + it.each([DurableTaskAzureManagedClientBuilder, DurableTaskAzureManagedWorkerBuilder])( + "%s preserves connection-string replacement and explicit setter precedence", + async (Builder) => { + const credential = recordingCredential(); + const builder = new Builder() + .resourceId("api://discarded") + .connectionString(connectionString("api://from-string", credential)); + await metadataFrom(builder.build())(); + builder.resourceId("api://override/.default/.default"); + await metadataFrom(builder.build())(); + expect(credential.getToken.mock.calls).toEqual([ + ["api://from-string/.default", undefined], + ["api://override/.default/.default", undefined], + ]); + }, + ); +}); From bb69228212a1125cb8e409f7e70b6140167cb9be Mon Sep 17 00:00:00 2001 From: Bernd Verst Date: Mon, 28 Sep 2026 11:01:36 -0700 Subject: [PATCH 2/2] fix(azuremanaged): normalize token audiences in linear time Replace backtracking slash-trimming regexes with a bounded suffix check and index scans. Cover slash-heavy inputs and scope preservation across all authentication paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../durabletask-js-azuremanaged/CHANGELOG.md | 3 ++- .../src/options.ts | 18 ++++++++++--- .../test/unit/resource-id.spec.ts | 25 +++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/durabletask-js-azuremanaged/CHANGELOG.md b/packages/durabletask-js-azuremanaged/CHANGELOG.md index 2017ef9e..60244b3c 100644 --- a/packages/durabletask-js-azuremanaged/CHANGELOG.md +++ b/packages/durabletask-js-azuremanaged/CHANGELOG.md @@ -5,7 +5,8 @@ - Support the `ResourceId` token audience URI in connection strings and an optional final `resourceId` argument in the client/worker factory functions, alongside existing builder/options setters. Trim surrounding whitespace and trailing slashes, remove one case-insensitive `/.default` suffix, - and reject nonempty values that normalize to empty. Preserve custom URI casing. + and reject nonempty values that normalize to empty. Preserve custom URI casing and normalize + slash-heavy inputs in linear time. - Support optional connection-string `AuthorityHost` for SDK-created Azure Identity credentials that support authority configuration. Omission preserves Azure Identity defaults/environment settings. Caller-supplied credentials own their authority; managed identity and developer-tool clouds remain separate. diff --git a/packages/durabletask-js-azuremanaged/src/options.ts b/packages/durabletask-js-azuremanaged/src/options.ts index c49d9172..e2b3ac4b 100644 --- a/packages/durabletask-js-azuremanaged/src/options.ts +++ b/packages/durabletask-js-azuremanaged/src/options.ts @@ -59,13 +59,25 @@ abstract class DurableTaskAzureManagedOptionsBase { return this._defaultResourceId; } - const normalized = resourceId.trim().replace(/\/+$/, "").replace(/\/\.default$/i, "").replace(/\/+$/, ""); - if (normalized === "") { + const trimmed = resourceId.trim(); + let end = trimmed.length; + // Scan trailing slashes directly to avoid regex backtracking on long internal slash runs. + while (end > 0 && trimmed[end - 1] === "/") { + end--; + } + const scopeSuffix = "/.default"; + if (end >= scopeSuffix.length && trimmed.slice(end - scopeSuffix.length, end).toLowerCase() === scopeSuffix) { + end -= scopeSuffix.length; + while (end > 0 && trimmed[end - 1] === "/") { + end--; + } + } + if (end === 0) { throw new Error( "resourceId (ResourceId) cannot be empty after normalization. Provide a token audience URI, or omit it to use the region default.", ); } - return normalized; + return trimmed.slice(0, end); } /** diff --git a/packages/durabletask-js-azuremanaged/test/unit/resource-id.spec.ts b/packages/durabletask-js-azuremanaged/test/unit/resource-id.spec.ts index 092cfa62..ef3ee55a 100644 --- a/packages/durabletask-js-azuremanaged/test/unit/resource-id.spec.ts +++ b/packages/durabletask-js-azuremanaged/test/unit/resource-id.spec.ts @@ -4,6 +4,7 @@ import { DefaultAzureCredential, TokenCredential } from "@azure/identity"; import * as grpc from "@grpc/grpc-js"; import { TaskHubGrpcClient, TaskHubGrpcWorker } from "@microsoft/durabletask-js"; +import { performance } from "node:perf_hooks"; import { createAzureManagedClient, createAzureManagedWorkerBuilder, @@ -212,6 +213,30 @@ describe("Resource audience", () => { expect(() => create(null, resourceId)).toThrow(/resourceId.*cannot be empty after normalization/); }); + it("normalizes long slash runs without backtracking or changing the requested scope", async () => { + const slashes = "/".repeat(100_000); + const cases = [ + [`api://Custom/${slashes}resource/.DEFAULT/`, `api://Custom/${slashes}resource`], + [`api://Custom${slashes}.DEFAULT${slashes}`, "api://Custom"], + [`api://Custom/${slashes}.default/resource`, `api://Custom/${slashes}.default/resource`], + [`api://Custom/.default/.DEFAULT${slashes}`, "api://Custom/.default"], + ]; + const credential = recordingCredential(); + for (const [resourceId, expected] of cases) { + const start = performance.now(); + const generate = create(credential, resourceId); + // A generous bound: the old regexes take seconds even on smaller inputs. + expect(performance.now() - start).toBeLessThan(1000); + await generate(); + expect(credential.getToken).toHaveBeenLastCalledWith(`${expected}/.default`, undefined); + } + expect(credential.getToken).toHaveBeenCalledTimes(cases.length); + expect(() => create(null, slashes)).toThrow(/resourceId.*cannot be empty after normalization/); + expect(() => create(null, `${slashes}.DEFAULT${slashes}`)).toThrow( + /resourceId.*cannot be empty after normalization/, + ); + }); + it("resolves defaults independently for each instance", async () => { const credential = recordingCredential(); setRegion("usgovvirginia");