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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions examples/azure-managed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-scheduler>.usgovvirginia.durabletask.azure.us;Authentication=DefaultAzure;TaskHub=<your-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.
Expand Down
14 changes: 14 additions & 0 deletions packages/durabletask-js-azuremanaged/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

### 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 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.
- 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
Expand All @@ -12,6 +20,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://`.

Expand Down
92 changes: 89 additions & 3 deletions packages/durabletask-js-azuremanaged/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<normalized-resource-id>/.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,
Expand Down Expand Up @@ -92,9 +167,12 @@ The connection string `Authentication` parameter supports the following values:
## Connection String Format

```
Endpoint=<endpoint>;Authentication=<auth-type>;TaskHub=<task-hub-name>[;ClientID=<client-id>][;TenantId=<tenant-id>]
Endpoint=<endpoint>;Authentication=<auth-type>;TaskHub=<task-hub-name>[;ClientID=<client-id>][;TenantId=<tenant-id>][;ResourceId=<token-audience-uri>][;AuthorityHost=<authority-url>]
```

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:
Expand Down Expand Up @@ -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
19 changes: 14 additions & 5 deletions packages/durabletask-js-azuremanaged/src/client-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -185,25 +190,29 @@ 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.
*/
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();
Expand Down
25 changes: 22 additions & 3 deletions packages/durabletask-js-azuremanaged/src/connection-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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());
}
}

Expand Down
11 changes: 7 additions & 4 deletions packages/durabletask-js-azuremanaged/src/credential-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -45,6 +47,7 @@ export function getCredentialFromAuthenticationType(
const additionallyAllowedTenants = connectionString.getAdditionallyAllowedTenants();

return new WorkloadIdentityCredential({
...authorityOptions,
...(clientId && { clientId }),
...(tenantId && { tenantId }),
...(tokenFilePath && { tokenFilePath }),
Expand All @@ -53,7 +56,7 @@ export function getCredentialFromAuthenticationType(
}

case "environment":
return new EnvironmentCredential();
return new EnvironmentCredential(authorityOptions);

case "azurecli":
return new AzureCliCredential();
Expand All @@ -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;
Expand Down
Loading
Loading