From dbc12ec7ad8fb14dc03757c011be8c7039cd2fae Mon Sep 17 00:00:00 2001 From: Timo van Veenendaal Date: Tue, 8 Oct 2024 15:43:27 -0700 Subject: [PATCH] [keyvault] CAE support (#31140) ### Packages impacted by this PR - `@azure/keyvault-common` - Downstream Key Vault packages ### Issues associated with this PR - Private ### Describe the problem that is addressed by this PR In future, the Key Vault service will be adding support for Continuous Access Evaluation (CAE). This PR adds the necessary support to the SDK's challenge-based authentication policy to enable this feature. After the initial challenge, with CAE enabled, any future request may result in a 401 response, even if the access token used is valid. This PR adds a new policy that handles this CAE challenge alongside the normal challenge. The new policy replaces the existing use of Core's `bearerTokenAuthenticationPolicy`, which is no longer suitable for this use case since it cannot handle a CAE challenge that comes immediately after a regular challenge. ### Are there test cases added in this PR? _(If not, why?)_ Yes, added test cases with mock requests and responses to cover a number of different scenarios, ensuring the policy is doing the right thing. I also manually tested against a test resource provided by the Key Vault team which returns a CAE challenge in response to any authorized request to the vault, and got the expected result (a normal challenge handled successfully, followed by a CAE challenge handled successfully, followed by another CAE challenge which the policy does not handle). ### Provide a list of related PRs _(if any)_ - Java PR for same feature: https://github.com/Azure/azure-sdk-for-java/pull/41814 --- sdk/keyvault/keyvault-admin/CHANGELOG.md | 4 +- sdk/keyvault/keyvault-admin/package.json | 4 +- .../keyvault-admin/src/accessControlClient.ts | 17 +- .../keyvault-admin/src/backupClient.ts | 17 +- sdk/keyvault/keyvault-admin/src/constants.ts | 2 +- .../src/generated/keyVaultClientContext.ts | 2 +- .../keyvault-admin/src/settingsClient.ts | 16 +- sdk/keyvault/keyvault-admin/swagger/README.md | 2 +- .../keyvault-certificates/CHANGELOG.md | 4 +- .../keyvault-certificates/package.json | 4 +- .../keyvault-certificates/src/constants.ts | 2 +- .../src/generated/keyVaultClient.ts | 2 +- .../keyvault-certificates/src/index.ts | 16 +- .../keyvault-certificates/swagger/README.md | 2 +- sdk/keyvault/keyvault-common/CHANGELOG.md | 7 +- sdk/keyvault/keyvault-common/package.json | 6 +- .../review/keyvault-common.api.md | 14 +- sdk/keyvault/keyvault-common/src/index.ts | 2 +- ...icy.ts => keyVaultAuthenticationPolicy.ts} | 138 ++++++-- sdk/keyvault/keyvault-common/src/logger.ts | 6 + .../src/parseWWWAuthenticate.ts | 16 +- .../keyvault-common/src/tokenCycler.ts | 234 +++++++++++++ .../challengeAuthenticationCallbacks.spec.ts | 317 ------------------ .../keyVaultAuthenticationPolicy.spec.ts | 296 ++++++++++++++++ sdk/keyvault/keyvault-keys/CHANGELOG.md | 4 +- sdk/keyvault/keyvault-keys/package.json | 4 +- sdk/keyvault/keyvault-keys/src/constants.ts | 2 +- .../remoteCryptographyProvider.ts | 16 +- .../src/generated/keyVaultClient.ts | 2 +- sdk/keyvault/keyvault-keys/src/index.ts | 17 +- sdk/keyvault/keyvault-keys/swagger/README.md | 2 +- sdk/keyvault/keyvault-secrets/CHANGELOG.md | 4 +- sdk/keyvault/keyvault-secrets/package.json | 4 +- .../keyvault-secrets/src/constants.ts | 2 +- .../src/generated/keyVaultClient.ts | 2 +- sdk/keyvault/keyvault-secrets/src/index.ts | 17 +- .../keyvault-secrets/swagger/README.md | 2 +- 37 files changed, 760 insertions(+), 448 deletions(-) rename sdk/keyvault/keyvault-common/src/{challengeBasedAuthenticationPolicy.ts => keyVaultAuthenticationPolicy.ts} (55%) create mode 100644 sdk/keyvault/keyvault-common/src/logger.ts create mode 100644 sdk/keyvault/keyvault-common/src/tokenCycler.ts delete mode 100644 sdk/keyvault/keyvault-common/test/internal/challengeAuthenticationCallbacks.spec.ts create mode 100644 sdk/keyvault/keyvault-common/test/internal/keyVaultAuthenticationPolicy.spec.ts diff --git a/sdk/keyvault/keyvault-admin/CHANGELOG.md b/sdk/keyvault/keyvault-admin/CHANGELOG.md index 79feda736ad2..0f7a1dda0cc2 100644 --- a/sdk/keyvault/keyvault-admin/CHANGELOG.md +++ b/sdk/keyvault/keyvault-admin/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 4.5.1 (Unreleased) +## 4.6.0 (Unreleased) ### Features Added +- Add support for Continuous Access Evaluation (CAE). [#31140](https://github.com/Azure/azure-sdk-for-js/pull/31140) + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/keyvault/keyvault-admin/package.json b/sdk/keyvault/keyvault-admin/package.json index 7fa0dfa92bb0..7506a56652a2 100644 --- a/sdk/keyvault/keyvault-admin/package.json +++ b/sdk/keyvault/keyvault-admin/package.json @@ -2,7 +2,7 @@ "name": "@azure/keyvault-admin", "sdk-type": "client", "author": "Microsoft Corporation", - "version": "4.5.1", + "version": "4.6.0", "license": "MIT", "description": "Isomorphic client library for Azure KeyVault's administrative functions.", "homepage": "https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/keyvault/keyvault-admin/README.md", @@ -99,7 +99,7 @@ "@azure/core-rest-pipeline": "^1.1.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.0.0", - "@azure/keyvault-common": "^1.0.0", + "@azure/keyvault-common": "^2.0.0", "@azure/logger": "^1.0.0", "tslib": "^2.2.0" }, diff --git a/sdk/keyvault/keyvault-admin/src/accessControlClient.ts b/sdk/keyvault/keyvault-admin/src/accessControlClient.ts index c682bf3b022d..b832cd2774a7 100644 --- a/sdk/keyvault/keyvault-admin/src/accessControlClient.ts +++ b/sdk/keyvault/keyvault-admin/src/accessControlClient.ts @@ -23,8 +23,7 @@ import { LATEST_API_VERSION } from "./constants.js"; import { PagedAsyncIterableIterator } from "@azure/core-paging"; import { RoleAssignmentsListForScopeOptionalParams } from "./generated/models/index.js"; import { TokenCredential } from "@azure/core-auth"; -import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; -import { createKeyVaultChallengeCallbacks } from "@azure/keyvault-common"; +import { keyVaultAuthenticationPolicy } from "@azure/keyvault-common"; import { logger } from "./log.js"; import { mappings } from "./mappings.js"; import { tracingClient } from "./tracing.js"; @@ -87,15 +86,11 @@ export class KeyVaultAccessControlClient { this.client = new KeyVaultClient(serviceVersion, clientOptions); - this.client.pipeline.addPolicy( - bearerTokenAuthenticationPolicy({ - credential, - // The scopes will be populated in the challenge callbacks based on the WWW-authenticate header - // returned by the challenge, so pass an empty array as a placeholder. - scopes: [], - challengeCallbacks: createKeyVaultChallengeCallbacks(options), - }), - ); + // The authentication policy must come after the deserialization policy since the deserialization policy + // converts 401 responses to an Error, and we don't want to deal with that. + this.client.pipeline.addPolicy(keyVaultAuthenticationPolicy(credential, clientOptions), { + afterPolicies: ["deserializationPolicy"], + }); } /** diff --git a/sdk/keyvault/keyvault-admin/src/backupClient.ts b/sdk/keyvault/keyvault-admin/src/backupClient.ts index 628c7560475b..ff8b0ee9a370 100644 --- a/sdk/keyvault/keyvault-admin/src/backupClient.ts +++ b/sdk/keyvault/keyvault-admin/src/backupClient.ts @@ -21,8 +21,7 @@ import { KeyVaultSelectiveKeyRestorePoller } from "./lro/selectiveKeyRestore/pol import { LATEST_API_VERSION } from "./constants.js"; import { PollerLike } from "@azure/core-lro"; import { TokenCredential } from "@azure/core-auth"; -import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; -import { createKeyVaultChallengeCallbacks } from "@azure/keyvault-common"; +import { keyVaultAuthenticationPolicy } from "@azure/keyvault-common"; import { logger } from "./log.js"; import { mappings } from "./mappings.js"; @@ -89,15 +88,11 @@ export class KeyVaultBackupClient { }; this.client = new KeyVaultClient(apiVersion, clientOptions); - this.client.pipeline.addPolicy( - bearerTokenAuthenticationPolicy({ - credential, - // The scopes will be populated in the challenge callbacks based on the WWW-authenticate header - // returned by the challenge, so pass an empty array as a placeholder. - scopes: [], - challengeCallbacks: createKeyVaultChallengeCallbacks(options), - }), - ); + // The authentication policy must come after the deserialization policy since the deserialization policy + // converts 401 responses to an Error, and we don't want to deal with that. + this.client.pipeline.addPolicy(keyVaultAuthenticationPolicy(credential, clientOptions), { + afterPolicies: ["deserializationPolicy"], + }); } /** diff --git a/sdk/keyvault/keyvault-admin/src/constants.ts b/sdk/keyvault/keyvault-admin/src/constants.ts index 2eb876869020..708e4c6d7a33 100644 --- a/sdk/keyvault/keyvault-admin/src/constants.ts +++ b/sdk/keyvault/keyvault-admin/src/constants.ts @@ -4,7 +4,7 @@ /** * Current version of the Key Vault Admin SDK. */ -export const SDK_VERSION: string = "4.5.1"; +export const SDK_VERSION: string = "4.6.0"; /** * The latest supported Key Vault service API version. diff --git a/sdk/keyvault/keyvault-admin/src/generated/keyVaultClientContext.ts b/sdk/keyvault/keyvault-admin/src/generated/keyVaultClientContext.ts index 1802c70425c8..7dadb47ee170 100644 --- a/sdk/keyvault/keyvault-admin/src/generated/keyVaultClientContext.ts +++ b/sdk/keyvault/keyvault-admin/src/generated/keyVaultClientContext.ts @@ -33,7 +33,7 @@ export class KeyVaultClientContext extends coreClient.ServiceClient { requestContentType: "application/json; charset=utf-8" }; - const packageDetails = `azsdk-js-keyvault-admin/4.5.1`; + const packageDetails = `azsdk-js-keyvault-admin/4.6.0`; const userAgentPrefix = options.userAgentOptions && options.userAgentOptions.userAgentPrefix ? `${options.userAgentOptions.userAgentPrefix} ${packageDetails}` diff --git a/sdk/keyvault/keyvault-admin/src/settingsClient.ts b/sdk/keyvault/keyvault-admin/src/settingsClient.ts index 65216baa1148..86ff463e9041 100644 --- a/sdk/keyvault/keyvault-admin/src/settingsClient.ts +++ b/sdk/keyvault/keyvault-admin/src/settingsClient.ts @@ -2,8 +2,7 @@ // Licensed under the MIT License. import { TokenCredential } from "@azure/core-auth"; -import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; -import { createKeyVaultChallengeCallbacks } from "@azure/keyvault-common"; +import { keyVaultAuthenticationPolicy } from "@azure/keyvault-common"; import { LATEST_API_VERSION } from "./constants.js"; import { KeyVaultClient, Setting as GeneratedSetting } from "./generated/index.js"; import { logger } from "./log.js"; @@ -92,13 +91,12 @@ export class KeyVaultSettingsClient { }; this.client = new KeyVaultClient(apiVersion, clientOptions); - this.client.pipeline.addPolicy( - bearerTokenAuthenticationPolicy({ - credential, - scopes: [], - challengeCallbacks: createKeyVaultChallengeCallbacks(options), - }), - ); + + // The authentication policy must come after the deserialization policy since the deserialization policy + // converts 401 responses to an Error, and we don't want to deal with that. + this.client.pipeline.addPolicy(keyVaultAuthenticationPolicy(credential, clientOptions), { + afterPolicies: ["deserializationPolicy"], + }); } /** diff --git a/sdk/keyvault/keyvault-admin/swagger/README.md b/sdk/keyvault/keyvault-admin/swagger/README.md index a0e14971e5e4..838cc3d4c478 100644 --- a/sdk/keyvault/keyvault-admin/swagger/README.md +++ b/sdk/keyvault/keyvault-admin/swagger/README.md @@ -16,7 +16,7 @@ input-file: - https://raw.githubusercontent.com/Azure/azure-rest-api-specs/7452e1cc7db72fbc6cd9539b390d8b8e5c2a1864/specification/keyvault/data-plane/Microsoft.KeyVault/stable/7.5/settings.json output-folder: ../ source-code-folder-path: ./src/generated -package-version: 4.5.1 +package-version: 4.6.0 use-extension: "@autorest/typescript": "6.0.0-beta.15" ``` diff --git a/sdk/keyvault/keyvault-certificates/CHANGELOG.md b/sdk/keyvault/keyvault-certificates/CHANGELOG.md index f26ed2f7b72a..d986666eb875 100644 --- a/sdk/keyvault/keyvault-certificates/CHANGELOG.md +++ b/sdk/keyvault/keyvault-certificates/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 4.8.1 (Unreleased) +## 4.9.0 (Unreleased) ### Features Added +- Add support for Continuous Access Evaluation (CAE). [#31140](https://github.com/Azure/azure-sdk-for-js/pull/31140) + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/keyvault/keyvault-certificates/package.json b/sdk/keyvault/keyvault-certificates/package.json index 9b34eaa28e81..535d2e7d3ecb 100644 --- a/sdk/keyvault/keyvault-certificates/package.json +++ b/sdk/keyvault/keyvault-certificates/package.json @@ -2,7 +2,7 @@ "name": "@azure/keyvault-certificates", "sdk-type": "client", "author": "Microsoft Corporation", - "version": "4.8.1", + "version": "4.9.0", "license": "MIT", "description": "Isomorphic client library for Azure KeyVault's certificates.", "homepage": "https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/keyvault/keyvault-certificates/README.md", @@ -103,7 +103,7 @@ "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.6.1", - "@azure/keyvault-common": "^1.0.0", + "@azure/keyvault-common": "^2.0.0", "@azure/logger": "^1.0.0", "tslib": "^2.2.0" }, diff --git a/sdk/keyvault/keyvault-certificates/src/constants.ts b/sdk/keyvault/keyvault-certificates/src/constants.ts index 59040838a911..6d660debf3a8 100644 --- a/sdk/keyvault/keyvault-certificates/src/constants.ts +++ b/sdk/keyvault/keyvault-certificates/src/constants.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export const SDK_VERSION: string = "4.8.1"; +export const SDK_VERSION: string = "4.9.0"; diff --git a/sdk/keyvault/keyvault-certificates/src/generated/keyVaultClient.ts b/sdk/keyvault/keyvault-certificates/src/generated/keyVaultClient.ts index 6222c444c032..f410d831df8c 100644 --- a/sdk/keyvault/keyvault-certificates/src/generated/keyVaultClient.ts +++ b/sdk/keyvault/keyvault-certificates/src/generated/keyVaultClient.ts @@ -103,7 +103,7 @@ export class KeyVaultClient extends coreHttpCompat.ExtendedServiceClient { requestContentType: "application/json; charset=utf-8" }; - const packageDetails = `azsdk-js-keyvault-certificates/4.8.1`; + const packageDetails = `azsdk-js-keyvault-certificates/4.9.0`; const userAgentPrefix = options.userAgentOptions && options.userAgentOptions.userAgentPrefix ? `${options.userAgentOptions.userAgentPrefix} ${packageDetails}` diff --git a/sdk/keyvault/keyvault-certificates/src/index.ts b/sdk/keyvault/keyvault-certificates/src/index.ts index 24858fcd7593..1ffc0066e6a9 100644 --- a/sdk/keyvault/keyvault-certificates/src/index.ts +++ b/sdk/keyvault/keyvault-certificates/src/index.ts @@ -6,7 +6,6 @@ /// import { InternalClientPipelineOptions } from "@azure/core-client"; -import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; import { TokenCredential } from "@azure/core-auth"; @@ -100,7 +99,7 @@ import { } from "./generated/models/index.js"; import { KeyVaultClient } from "./generated/keyVaultClient.js"; import { PageSettings, PagedAsyncIterableIterator } from "@azure/core-paging"; -import { createKeyVaultChallengeCallbacks } from "@azure/keyvault-common"; +import { keyVaultAuthenticationPolicy } from "@azure/keyvault-common"; import { CreateCertificatePoller } from "./lro/create/poller.js"; import { CertificateOperationPoller } from "./lro/operation/poller.js"; import { DeleteCertificatePoller } from "./lro/delete/poller.js"; @@ -247,12 +246,6 @@ export class CertificateClient { ) { this.vaultUrl = vaultUrl; - const authPolicy = bearerTokenAuthenticationPolicy({ - credential, - scopes: [], - challengeCallbacks: createKeyVaultChallengeCallbacks(clientOptions), - }); - const internalClientPipelineOptions: InternalClientPipelineOptions = { ...clientOptions, loggingOptions: { @@ -269,7 +262,12 @@ export class CertificateClient { clientOptions.serviceVersion || LATEST_API_VERSION, internalClientPipelineOptions, ); - this.client.pipeline.addPolicy(authPolicy); + + // The authentication policy must come after the deserialization policy since the deserialization policy + // converts 401 responses to an Error, and we don't want to deal with that. + this.client.pipeline.addPolicy(keyVaultAuthenticationPolicy(credential, clientOptions), { + afterPolicies: ["deserializationPolicy"], + }); } private async *listPropertiesOfCertificatesPage( diff --git a/sdk/keyvault/keyvault-certificates/swagger/README.md b/sdk/keyvault/keyvault-certificates/swagger/README.md index eb3438fb83e7..31d11451fc65 100644 --- a/sdk/keyvault/keyvault-certificates/swagger/README.md +++ b/sdk/keyvault/keyvault-certificates/swagger/README.md @@ -20,7 +20,7 @@ input-file: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/7452e1c output-folder: ../ source-code-folder-path: ./src/generated hide-clients: true -package-version: 4.8.1 +package-version: 4.9.0 openapi-type: data-plane ``` diff --git a/sdk/keyvault/keyvault-common/CHANGELOG.md b/sdk/keyvault/keyvault-common/CHANGELOG.md index 6487b537e5ac..e4a11e55342f 100644 --- a/sdk/keyvault/keyvault-common/CHANGELOG.md +++ b/sdk/keyvault/keyvault-common/CHANGELOG.md @@ -1,11 +1,16 @@ # Release History -## 1.0.1 (Unreleased) +## 2.0.0 (Unreleased) ### Features Added +- Add support for Continuous Access Evaluation (CAE). + - To take advantage of this support, the newly added `keyVaultAuthenticationPolicy` should be used in place of `bearerTokenAuthenticationPolicy`. + ### Breaking Changes +- Removed `createKeyVaultChallengeCallbacks`, which was used to add Key Vault specific handling to Core's `bearerTokenAuthenticationPolicy`. The new `keyVaultAuthenticationPolicy` should be used instead. + ### Bugs Fixed ### Other Changes diff --git a/sdk/keyvault/keyvault-common/package.json b/sdk/keyvault/keyvault-common/package.json index d48f5e6b82e5..8f9d2b94b9fc 100644 --- a/sdk/keyvault/keyvault-common/package.json +++ b/sdk/keyvault/keyvault-common/package.json @@ -1,6 +1,6 @@ { "name": "@azure/keyvault-common", - "version": "1.0.1", + "version": "2.0.0", "description": "Common internal functionality for all of the Azure Key Vault clients in the Azure SDK for JavaScript", "sdk-type": "client", "author": "Microsoft Corporation", @@ -59,7 +59,9 @@ "@azure/core-client": "^1.5.0", "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", - "tslib": "^2.2.0" + "@azure/logger": "^1.1.5", + "tslib": "^2.2.0", + "@azure/core-util": "^1.10.1" }, "devDependencies": { "@azure-tools/test-utils-vitest": "^1.0.0", diff --git a/sdk/keyvault/keyvault-common/review/keyvault-common.api.md b/sdk/keyvault/keyvault-common/review/keyvault-common.api.md index 32c5277aff13..99d9590a6b8b 100644 --- a/sdk/keyvault/keyvault-common/review/keyvault-common.api.md +++ b/sdk/keyvault/keyvault-common/review/keyvault-common.api.md @@ -4,15 +4,19 @@ ```ts -import { ChallengeCallbacks } from '@azure/core-rest-pipeline'; +import { PipelinePolicy } from '@azure/core-rest-pipeline'; +import { TokenCredential } from '@azure/core-auth'; // @public -export interface CreateChallengeCallbacksOptions { - disableChallengeResourceVerification?: boolean; -} +export function keyVaultAuthenticationPolicy(credential: TokenCredential, options?: KeyVaultAuthenticationPolicyOptions): PipelinePolicy; + +// @public +export const keyVaultAuthenticationPolicyName = "keyVaultAuthenticationPolicy"; // @public -export function createKeyVaultChallengeCallbacks(options?: CreateChallengeCallbacksOptions): ChallengeCallbacks; +export interface KeyVaultAuthenticationPolicyOptions { + disableChallengeResourceVerification?: boolean; +} // @public export interface KeyVaultEntityIdentifier { diff --git a/sdk/keyvault/keyvault-common/src/index.ts b/sdk/keyvault/keyvault-common/src/index.ts index e9fc51a25090..e3c7747b9a97 100644 --- a/sdk/keyvault/keyvault-common/src/index.ts +++ b/sdk/keyvault/keyvault-common/src/index.ts @@ -1,5 +1,5 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from "./challengeBasedAuthenticationPolicy.js"; +export * from "./keyVaultAuthenticationPolicy.js"; export * from "./parseKeyVaultIdentifier.js"; diff --git a/sdk/keyvault/keyvault-common/src/challengeBasedAuthenticationPolicy.ts b/sdk/keyvault/keyvault-common/src/keyVaultAuthenticationPolicy.ts similarity index 55% rename from sdk/keyvault/keyvault-common/src/challengeBasedAuthenticationPolicy.ts rename to sdk/keyvault/keyvault-common/src/keyVaultAuthenticationPolicy.ts index 54f8eba7d07c..017978f5ea4c 100644 --- a/sdk/keyvault/keyvault-common/src/challengeBasedAuthenticationPolicy.ts +++ b/sdk/keyvault/keyvault-common/src/keyVaultAuthenticationPolicy.ts @@ -2,15 +2,17 @@ // Licensed under the MIT License. import { - AuthorizeRequestOnChallengeOptions, - AuthorizeRequestOptions, - ChallengeCallbacks, + PipelinePolicy, PipelineRequest, + PipelineResponse, RequestBodyType, + SendRequest, } from "@azure/core-rest-pipeline"; import { WWWAuthenticate, parseWWWAuthenticateHeader } from "./parseWWWAuthenticate.js"; -import { GetTokenOptions } from "@azure/core-auth"; +import { GetTokenOptions, TokenCredential } from "@azure/core-auth"; +import { createTokenCycler } from "./tokenCycler.js"; +import { logger } from "./logger.js"; /** * @internal @@ -35,12 +37,13 @@ type ChallengeState = | { status: "complete"; scopes: string[]; + tenantId?: string; }; /** * Additional options for the challenge based authentication policy. */ -export interface CreateChallengeCallbacksOptions { +export interface KeyVaultAuthenticationPolicyOptions { /** * Whether to disable verification that the challenge resource matches the Key Vault or Managed HSM domain. * @@ -67,7 +70,12 @@ function verifyChallengeResource(scope: string, request: PipelineRequest): void } /** - * Creates challenge callback handlers to manage CAE lifecycle in Azure Key Vault. + * Name of the Key Vault authentication policy. + */ +export const keyVaultAuthenticationPolicyName = "keyVaultAuthenticationPolicy"; + +/** + * A custom implementation of the bearer-token authentication policy that handles Key Vault and CAE challenges. * * Key Vault supports other authentication schemes, but we ensure challenge authentication * is used by first sending a copy of the request, without authorization or content. @@ -79,11 +87,13 @@ function verifyChallengeResource(scope: string, request: PipelineRequest): void * if possible. * */ -export function createKeyVaultChallengeCallbacks( - options: CreateChallengeCallbacksOptions = {}, -): ChallengeCallbacks { +export function keyVaultAuthenticationPolicy( + credential: TokenCredential, + options: KeyVaultAuthenticationPolicyOptions = {}, +): PipelinePolicy { const { disableChallengeResourceVerification } = options; let challengeState: ChallengeState = { status: "none" }; + const getAccessToken = createTokenCycler(credential); function requestToOptions(request: PipelineRequest): GetTokenOptions { return { @@ -95,10 +105,7 @@ export function createKeyVaultChallengeCallbacks( }; } - async function authorizeRequest({ - request, - getAccessToken, - }: AuthorizeRequestOptions): Promise { + async function authorizeRequest(request: PipelineRequest): Promise { const requestOptions: GetTokenOptions = requestToOptions(request); switch (challengeState.status) { @@ -112,21 +119,29 @@ export function createKeyVaultChallengeCallbacks( case "started": break; // Retry, we should not overwrite the original body case "complete": { - const token = await getAccessToken(challengeState.scopes, requestOptions); + const token = await getAccessToken(challengeState.scopes, { + ...requestOptions, + enableCae: true, + tenantId: challengeState.tenantId, + }); if (token) { request.headers.set("authorization", `Bearer ${token.token}`); } break; } } - return Promise.resolve(); } - async function authorizeRequestOnChallenge({ - request, - response, - getAccessToken, - }: AuthorizeRequestOnChallengeOptions): Promise { + async function handleChallenge( + request: PipelineRequest, + response: PipelineResponse, + next: SendRequest, + ): Promise { + // If status is not 401, this is a no-op + if (response.status !== 401) { + return response; + } + if (request.body === null && challengeState.status === "started") { // Reset the original body before doing anything else. // Note: If successful status will be "complete", otherwise "none" will @@ -138,16 +153,20 @@ export function createKeyVaultChallengeCallbacks( const challenge = response.headers.get("WWW-Authenticate"); if (!challenge) { - throw new Error("Missing challenge."); + logger.warning( + "keyVaultAuthentication policy encountered a 401 response without a corresponding WWW-Authenticate header. This is unexpected. Not handling the 401 response.", + ); + return response; } - const parsedChallenge: WWWAuthenticate = parseWWWAuthenticateHeader(challenge) || {}; + const parsedChallenge: WWWAuthenticate = parseWWWAuthenticateHeader(challenge); const scope = parsedChallenge.resource ? parsedChallenge.resource + "/.default" : parsedChallenge.scope; if (!scope) { - throw new Error("Missing scope."); + // Cannot handle this kind of challenge here (if scope is not present, may be a CAE challenge) + return response; } if (!disableChallengeResourceVerification) { @@ -156,11 +175,13 @@ export function createKeyVaultChallengeCallbacks( const accessToken = await getAccessToken([scope], { ...getTokenOptions, + enableCae: true, tenantId: parsedChallenge.tenantId, }); if (!accessToken) { - return false; + // No access token provided, treat as no-op + return response; } request.headers.set("Authorization", `Bearer ${accessToken.token}`); @@ -168,13 +189,76 @@ export function createKeyVaultChallengeCallbacks( challengeState = { status: "complete", scopes: [scope], + tenantId: parsedChallenge.tenantId, }; - return true; + // We have a token now, so try send the request again + return next(request); + } + + async function handleCaeChallenge( + request: PipelineRequest, + response: PipelineResponse, + next: SendRequest, + ): Promise { + // Cannot handle CAE challenge if a regular challenge has not been completed first + if (challengeState.status !== "complete") { + return response; + } + + // If status is not 401, this is a no-op + if (response.status !== 401) { + return response; + } + + const getTokenOptions = requestToOptions(request); + + const challenge = response.headers.get("WWW-Authenticate"); + if (!challenge) { + return response; + } + const { claims: base64EncodedClaims, error }: WWWAuthenticate = + parseWWWAuthenticateHeader(challenge); + + if (error !== "insufficient_claims" || base64EncodedClaims === undefined) { + return response; + } + + const claims = atob(base64EncodedClaims); + + const accessToken = await getAccessToken(challengeState.scopes, { + ...getTokenOptions, + enableCae: true, + tenantId: challengeState.tenantId, + claims, + }); + + request.headers.set("Authorization", `Bearer ${accessToken.token}`); + + return next(request); + } + + async function sendRequest( + request: PipelineRequest, + next: SendRequest, + ): Promise { + // Add token if possible + await authorizeRequest(request); + + // Try send request (first attempt) + let response = await next(request); + + // Handle standard challenge if present + response = await handleChallenge(request, response, next); + + // Handle CAE challenge if present + response = await handleCaeChallenge(request, response, next); + + return response; } return { - authorizeRequest, - authorizeRequestOnChallenge, + name: keyVaultAuthenticationPolicyName, + sendRequest, }; } diff --git a/sdk/keyvault/keyvault-common/src/logger.ts b/sdk/keyvault/keyvault-common/src/logger.ts new file mode 100644 index 000000000000..fddc58835663 --- /dev/null +++ b/sdk/keyvault/keyvault-common/src/logger.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createClientLogger } from "@azure/logger"; + +export const logger = createClientLogger("keyvault-common"); diff --git a/sdk/keyvault/keyvault-common/src/parseWWWAuthenticate.ts b/sdk/keyvault/keyvault-common/src/parseWWWAuthenticate.ts index 343d91a6fb23..1082ce3f6703 100644 --- a/sdk/keyvault/keyvault-common/src/parseWWWAuthenticate.ts +++ b/sdk/keyvault/keyvault-common/src/parseWWWAuthenticate.ts @@ -29,6 +29,16 @@ export interface WWWAuthenticate { * The tenantId parameter, if present. */ tenantId?: string; + + /** + * The claims parameter, if present. + */ + claims?: string; + + /** + * The error parameter, if present. + */ + error?: string; } const validWWWAuthenticateProperties: readonly (keyof WWWAuthenticate)[] = [ @@ -37,6 +47,8 @@ const validWWWAuthenticateProperties: readonly (keyof WWWAuthenticate)[] = [ "resource", "scope", "tenantId", + "claims", + "error", ] as const; /** @@ -52,10 +64,10 @@ export function parseWWWAuthenticateHeader(headerValue: string): WWWAuthenticate const parsed = headerValue.split(pairDelimiter).reduce((kvPairs, p) => { if (p.match(/\w="/)) { // 'sampleKey="sample_value"' -> [sampleKey, "sample_value"] -> { sampleKey: sample_value } - const [key, value] = p.split("="); + const [key, ...value] = p.split("="); if (validWWWAuthenticateProperties.includes(key as keyof WWWAuthenticate)) { // The values will be wrapped in quotes, which need to be stripped out. - return { ...kvPairs, [key]: value.slice(1, -1) }; + return { ...kvPairs, [key]: value.join("=").slice(1, -1) }; } } return kvPairs; diff --git a/sdk/keyvault/keyvault-common/src/tokenCycler.ts b/sdk/keyvault/keyvault-common/src/tokenCycler.ts new file mode 100644 index 000000000000..06de02bbcc70 --- /dev/null +++ b/sdk/keyvault/keyvault-common/src/tokenCycler.ts @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This file is a direct copy of the tokenCycler implementation in core-rest-pipeline. + +import type { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth"; +import { delay } from "@azure/core-util"; + +/** + * A function that gets a promise of an access token and allows providing + * options. + * + * @param options - the options to pass to the underlying token provider + */ +export type AccessTokenGetter = ( + scopes: string | string[], + options: GetTokenOptions, +) => Promise; + +export interface TokenCyclerOptions { + /** + * The window of time before token expiration during which the token will be + * considered unusable due to risk of the token expiring before sending the + * request. + * + * This will only become meaningful if the refresh fails for over + * (refreshWindow - forcedRefreshWindow) milliseconds. + */ + forcedRefreshWindowInMs: number; + /** + * Interval in milliseconds to retry failed token refreshes. + */ + retryIntervalInMs: number; + /** + * The window of time before token expiration during which + * we will attempt to refresh the token. + */ + refreshWindowInMs: number; +} + +// Default options for the cycler if none are provided +export const DEFAULT_CYCLER_OPTIONS: TokenCyclerOptions = { + forcedRefreshWindowInMs: 1000, // Force waiting for a refresh 1s before the token expires + retryIntervalInMs: 3000, // Allow refresh attempts every 3s + refreshWindowInMs: 1000 * 60 * 2, // Start refreshing 2m before expiry +}; + +/** + * Converts an an unreliable access token getter (which may resolve with null) + * into an AccessTokenGetter by retrying the unreliable getter in a regular + * interval. + * + * @param getAccessToken - A function that produces a promise of an access token that may fail by returning null. + * @param retryIntervalInMs - The time (in milliseconds) to wait between retry attempts. + * @param refreshTimeout - The timestamp after which the refresh attempt will fail, throwing an exception. + * @returns - A promise that, if it resolves, will resolve with an access token. + */ +async function beginRefresh( + getAccessToken: () => Promise, + retryIntervalInMs: number, + refreshTimeout: number, +): Promise { + // This wrapper handles exceptions gracefully as long as we haven't exceeded + // the timeout. + async function tryGetAccessToken(): Promise { + if (Date.now() < refreshTimeout) { + try { + return await getAccessToken(); + } catch { + return null; + } + } else { + const finalToken = await getAccessToken(); + + // Timeout is up, so throw if it's still null + if (finalToken === null) { + throw new Error("Failed to refresh access token."); + } + + return finalToken; + } + } + + let token: AccessToken | null = await tryGetAccessToken(); + + while (token === null) { + await delay(retryIntervalInMs); + + token = await tryGetAccessToken(); + } + + return token; +} + +/** + * Creates a token cycler from a credential, scopes, and optional settings. + * + * A token cycler represents a way to reliably retrieve a valid access token + * from a TokenCredential. It will handle initializing the token, refreshing it + * when it nears expiration, and synchronizes refresh attempts to avoid + * concurrency hazards. + * + * @param credential - the underlying TokenCredential that provides the access + * token + * @param tokenCyclerOptions - optionally override default settings for the cycler + * + * @returns - a function that reliably produces a valid access token + */ +export function createTokenCycler( + credential: TokenCredential, + tokenCyclerOptions?: Partial, +): AccessTokenGetter { + let refreshWorker: Promise | null = null; + let token: AccessToken | null = null; + let tenantId: string | undefined; + + const options = { + ...DEFAULT_CYCLER_OPTIONS, + ...tokenCyclerOptions, + }; + + /** + * This little holder defines several predicates that we use to construct + * the rules of refreshing the token. + */ + const cycler = { + /** + * Produces true if a refresh job is currently in progress. + */ + get isRefreshing(): boolean { + return refreshWorker !== null; + }, + /** + * Produces true if the cycler SHOULD refresh (we are within the refresh + * window and not already refreshing) + */ + get shouldRefresh(): boolean { + if (cycler.isRefreshing) { + return false; + } + if (token?.refreshAfterTimestamp && token.refreshAfterTimestamp < Date.now()) { + return true; + } + + return (token?.expiresOnTimestamp ?? 0) - options.refreshWindowInMs < Date.now(); + }, + /** + * Produces true if the cycler MUST refresh (null or nearly-expired + * token). + */ + get mustRefresh(): boolean { + return ( + token === null || token.expiresOnTimestamp - options.forcedRefreshWindowInMs < Date.now() + ); + }, + }; + + /** + * Starts a refresh job or returns the existing job if one is already + * running. + */ + function refresh( + scopes: string | string[], + getTokenOptions: GetTokenOptions, + ): Promise { + if (!cycler.isRefreshing) { + // We bind `scopes` here to avoid passing it around a lot + const tryGetAccessToken = (): Promise => + credential.getToken(scopes, getTokenOptions); + + // Take advantage of promise chaining to insert an assignment to `token` + // before the refresh can be considered done. + refreshWorker = beginRefresh( + tryGetAccessToken, + options.retryIntervalInMs, + // If we don't have a token, then we should timeout immediately + token?.expiresOnTimestamp ?? Date.now(), + ) + .then((_token) => { + refreshWorker = null; + token = _token; + tenantId = getTokenOptions.tenantId; + return token; + }) + .catch((reason) => { + // We also should reset the refresher if we enter a failed state. All + // existing awaiters will throw, but subsequent requests will start a + // new retry chain. + refreshWorker = null; + token = null; + tenantId = undefined; + throw reason; + }); + } + + return refreshWorker as Promise; + } + + return async (scopes: string | string[], tokenOptions: GetTokenOptions): Promise => { + // + // Simple rules: + // - If we MUST refresh, then return the refresh task, blocking + // the pipeline until a token is available. + // - If we SHOULD refresh, then run refresh but don't return it + // (we can still use the cached token). + // - Return the token, since it's fine if we didn't return in + // step 1. + // + + const hasClaimChallenge = Boolean(tokenOptions.claims); + const tenantIdChanged = tenantId !== tokenOptions.tenantId; + + if (hasClaimChallenge) { + // If we've received a claim, we know the existing token isn't valid + // We want to clear it so that that refresh worker won't use the old expiration time as a timeout + token = null; + } + + // If the tenantId passed in token options is different to the one we have + // Or if we are in claim challenge and the token was rejected and a new access token need to be issued, we need to + // refresh the token with the new tenantId or token. + const mustRefresh = tenantIdChanged || hasClaimChallenge || cycler.mustRefresh; + + if (mustRefresh) { + return refresh(scopes, tokenOptions); + } + + if (cycler.shouldRefresh) { + refresh(scopes, tokenOptions); + } + + return token as AccessToken; + }; +} diff --git a/sdk/keyvault/keyvault-common/test/internal/challengeAuthenticationCallbacks.spec.ts b/sdk/keyvault/keyvault-common/test/internal/challengeAuthenticationCallbacks.spec.ts deleted file mode 100644 index 1145edba04aa..000000000000 --- a/sdk/keyvault/keyvault-common/test/internal/challengeAuthenticationCallbacks.spec.ts +++ /dev/null @@ -1,317 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { - AuthorizeRequestOptions, - ChallengeCallbacks, - PipelineRequest, - createHttpHeaders, - createPipelineRequest, -} from "@azure/core-rest-pipeline"; -import { createKeyVaultChallengeCallbacks } from "../../src/index.js"; -import { parseWWWAuthenticateHeader } from "../../src/parseWWWAuthenticate.js"; -import { describe, it, beforeEach, expect } from "vitest"; - -describe("Challenge based authentication tests", function () { - let request: PipelineRequest; - let challengeCallbacks: ChallengeCallbacks; - - beforeEach(() => { - request = createPipelineRequest({ url: "https://myvault.vault.azure.net" }); - challengeCallbacks = createKeyVaultChallengeCallbacks(); - }); - - describe("authorizeRequest", () => { - it("always starts the challenge on the first call", async () => { - let getAccessTokenCallCount = 0; - const options: AuthorizeRequestOptions = { - getAccessToken: () => { - getAccessTokenCallCount += 1; - return Promise.resolve({ token: "access_token", expiresOnTimestamp: 1000 }); - }, - request, - scopes: [], - }; - - await challengeCallbacks.authorizeRequest!(options); - - expect(options.request.headers.get("authorization")).toBeUndefined(); - // We do not call getAccessToken on the first request - expect(getAccessTokenCallCount).toEqual(0); - }); - - it("sets the authorization token if it gets one on subsequent calls", async () => { - let getAccessTokenCallCount = 0; - const options: AuthorizeRequestOptions = { - getAccessToken: () => { - getAccessTokenCallCount += 1; - return Promise.resolve({ token: "access_token", expiresOnTimestamp: 1000 }); - }, - request, - scopes: [], - }; - - // Set up the challenge state to complete by calling authorizeRequestOnChallenge first - await challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => { - return Promise.resolve({ token: "successful_token", expiresOnTimestamp: 999999999 }); - }, - request, - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net"`, - }), - request, - status: 200, - }, - scopes: [], - }); - - await challengeCallbacks.authorizeRequest!(options); - - expect(options.request.headers.get("authorization")).toEqual("Bearer access_token"); - expect(getAccessTokenCallCount).toEqual(1); - }); - - it("does not modify headers when unable to get access token", async () => { - const options: AuthorizeRequestOptions = { - getAccessToken: () => { - return Promise.resolve(null); - }, - request: createPipelineRequest({ - url: "https://foo.bar", - headers: createHttpHeaders(), - }), - scopes: ["any_scope"], - }; - - await challengeCallbacks.authorizeRequest!(options); - - expect(options.request.headers.get("authorization")).toBeUndefined(); - }); - }); - - describe("authorizeRequestOnChallenge", () => { - it("validates WWW-Authenticate exists", async () => { - await expect( - challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => Promise.resolve(null), - request, - response: { - headers: createHttpHeaders(), - request, - status: 200, - }, - scopes: [], - }), - ).rejects.toThrow("Missing challenge"); - }); - - it("passes the correct scopes if provided", async () => { - let getAccessTokenScopes: string[] = []; - await challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: (scopes) => { - getAccessTokenScopes = scopes; - return Promise.resolve(null); - }, - request, - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net"`, - }), - request, - status: 200, - }, - scopes: [], - }); - - expect(getAccessTokenScopes).to.deep.equal(["https://vault.azure.net/.default"]); - }); - - it("throws if the resource is not a valid URL", async () => { - await expect( - challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => Promise.resolve(null), - request, - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="invalid_scope"`, - }), - request, - status: 200, - }, - scopes: [], - }), - ).rejects.toThrow(`The challenge contains invalid scope 'invalid_scope/.default'`); - }); - - it("throws if the resource URI host does not match the request by default", async () => { - await expect( - challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => Promise.resolve(null), - request: createPipelineRequest({ url: "https://foo.bar" }), - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net"`, - }), - request, - status: 200, - }, - scopes: [], - }), - ).rejects.toThrow( - "The challenge resource 'vault.azure.net' does not match the requested domain. Set disableChallengeResourceVerification to true in your client options to disable. See https://aka.ms/azsdk/blog/vault-uri for more information.", - ); - }); - - it("throws if the request host is a prefix, but not a subdomain, of the resource URI host", async () => { - await expect( - challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => Promise.resolve(null), - request: createPipelineRequest({ url: "https://myvault.azure.net" }), - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net"`, - }), - request, - status: 200, - }, - scopes: [], - }), - ).rejects.toThrow( - "The challenge resource 'vault.azure.net' does not match the requested domain. Set disableChallengeResourceVerification to true in your client options to disable. See https://aka.ms/azsdk/blog/vault-uri for more information.", - ); - }); - - it("does not throw if the resource URI matches the request", async () => { - await challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => Promise.resolve(null), - request: createPipelineRequest({ url: "https://myvault.vault.azure.net" }), - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net"`, - }), - request, - status: 200, - }, - scopes: [], - }); - }); - - it("does not throw if the resource URI host does not match the request but verifyChallengeResource is false", async () => { - challengeCallbacks = createKeyVaultChallengeCallbacks({ - disableChallengeResourceVerification: true, - }); - await challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => Promise.resolve(null), - request: createPipelineRequest({ url: "https://foo.bar" }), - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net"`, - }), - request, - status: 200, - }, - scopes: [], - }); - }); - - it("passes the tenantId if provided", async () => { - const expectedTenantId = "expectedTenantId"; - - let getAccessTokenTenantId: string | undefined = ""; - - await challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: (_scopes, options) => { - getAccessTokenTenantId = options.tenantId; - return Promise.resolve(null); - }, - request, - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net" authorization="http://login.windows.net/${expectedTenantId}"`, - }), - request, - status: 200, - }, - scopes: [], - }); - - expect(getAccessTokenTenantId).toEqual(expectedTenantId); - }); - - it("returns true and sets the authorization header if challenge succeeds", async () => { - const result = await challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => { - return Promise.resolve({ token: "successful_token", expiresOnTimestamp: 999999999 }); - }, - request, - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net"`, - }), - request, - status: 200, - }, - scopes: [], - }); - expect(result).toEqual(true); - }); - - it("returns false and does not modify header if challenge fails", async () => { - const result = await challengeCallbacks.authorizeRequestOnChallenge!({ - getAccessToken: () => { - return Promise.resolve(null); - }, - request, - response: { - headers: createHttpHeaders({ - "WWW-Authenticate": `Bearer resource="https://vault.azure.net"`, - }), - request, - status: 200, - }, - scopes: [], - }); - expect(result).toEqual(false); - }); - }); - - describe("parseWWWAuthenticateHeader tests", () => { - it("Should work for known shapes of the WWW-Authenticate header", () => { - const wwwAuthenticate1 = `Bearer authorization="https://login.windows.net", resource="https://some.url"`; - const parsed1 = parseWWWAuthenticateHeader(wwwAuthenticate1); - expect(parsed1).to.deep.equal({ - authorization: "https://login.windows.net", - resource: "https://some.url", - }); - - const wwwAuthenticate2 = `Bearer authorization="https://login.windows.net/", scope="https://some.url"`; - const parsed2 = parseWWWAuthenticateHeader(wwwAuthenticate2); - expect(parsed2).to.deep.equal({ - authorization: "https://login.windows.net/", - scope: "https://some.url", - }); - }); - - it("Should ignore unknown values in the WWW-Authenticate header", () => { - const wwwAuthenticate1 = `Bearer authorization="https://login.windows.net", resource="https://some.url" scope="scope", a="a", b="b"`; - const parsed1 = parseWWWAuthenticateHeader(wwwAuthenticate1); - expect(parsed1).to.deep.equal({ - authorization: "https://login.windows.net", - resource: "https://some.url", - scope: "scope", - }); - }); - - it("should include the tenantId when present", () => { - const wwwAuthenticate1 = `Bearer authorization="https://login.windows.net/9999", resource="https://some.url"`; - const parsed1 = parseWWWAuthenticateHeader(wwwAuthenticate1); - expect(parsed1).to.deep.equal({ - authorization: "https://login.windows.net/9999", - resource: "https://some.url", - tenantId: "9999", - }); - }); - }); -}); diff --git a/sdk/keyvault/keyvault-common/test/internal/keyVaultAuthenticationPolicy.spec.ts b/sdk/keyvault/keyvault-common/test/internal/keyVaultAuthenticationPolicy.spec.ts new file mode 100644 index 000000000000..6c6e1cebef44 --- /dev/null +++ b/sdk/keyvault/keyvault-common/test/internal/keyVaultAuthenticationPolicy.spec.ts @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + Pipeline, + PipelineRequest, + SendRequest, + createEmptyPipeline, + createHttpHeaders, + createPipelineRequest, +} from "@azure/core-rest-pipeline"; +import { parseWWWAuthenticateHeader } from "../../src/parseWWWAuthenticate.js"; +import { describe, it, beforeEach, expect, vi } from "vitest"; +import { TokenCredential } from "@azure/core-auth"; +import { keyVaultAuthenticationPolicy } from "../../src/keyVaultAuthenticationPolicy.js"; + +const caeChallenge = `Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ=="`; +const caeClaims = `{"access_token":{"nbf":{"essential":true,"value":"1726077595"},"xms_caeerror":{"value":"10012"}}}`; + +const challengeResponse: SendRequest = async (req) => { + expect(req.body).toBeNull(); + return { + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer resource="https://vault.azure.net", authorization="http://login.windows.net/testTenantId"`, + }), + status: 401, + request: req, + }; +}; + +const caeChallengeResponse: SendRequest = async (req) => ({ + headers: createHttpHeaders({ + "WWW-Authenticate": caeChallenge, + }), + request: req, + status: 401, +}); + +const successfulResponseWith = + (options: { expectAuthorizationHeader?: string; responseBody?: string } = {}): SendRequest => + async (req) => { + if (options.expectAuthorizationHeader) { + expect(req.headers.get("Authorization")).toEqual(options.expectAuthorizationHeader); + } + + return { + headers: createHttpHeaders({}), + request: req, + bodyAsText: options.responseBody, + status: options.responseBody ? 200 : 204, + }; + }; + +const TOKEN_EXPIRY = 10000; + +const mockCredential: TokenCredential = { + getToken: async (scopes, options) => { + const [scope] = Array.isArray(scopes) ? scopes : [scopes]; + expect(scope).toBe("https://vault.azure.net/.default"); + expect(options?.tenantId).toBe("testTenantId"); + expect(options?.enableCae).toBe(true); + const later = new Date().getTime() + TOKEN_EXPIRY; + + if (options?.claims) { + expect(options.claims).toBe(caeClaims); + return { + token: "cae_token", + expiresOnTimestamp: later, + }; + } else { + return { + token: "access_token", + expiresOnTimestamp: later, + }; + } + }, +}; + +describe("Challenge based authentication tests", function () { + let request: PipelineRequest; + let pipeline: Pipeline; + + beforeEach(() => { + request = createPipelineRequest({ url: "https://myvault.vault.azure.net" }); + pipeline = createEmptyPipeline(); + pipeline.addPolicy(keyVaultAuthenticationPolicy(mockCredential)); + }); + + it("handles a challenge", async () => { + const sendRequest = vi + .fn() + .mockImplementationOnce(challengeResponse) + .mockImplementationOnce( + successfulResponseWith({ expectAuthorizationHeader: "Bearer access_token" }), + ); + + const rsp = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp.status).toBe(204); + }); + + it("handles a CAE challenge that comes immediately after a successful Key Vault challenge", async () => { + const sendRequest = vi + .fn() + // First, send the standard challenge + .mockImplementationOnce(challengeResponse) + // Then send a CAE challenge immediately after + .mockImplementationOnce(caeChallengeResponse) + // Finally, send a successful response, but only after asserting that the token in the Authorization header was obtained by passing the scopes through to the credential. + .mockImplementationOnce( + successfulResponseWith({ expectAuthorizationHeader: "Bearer cae_token" }), + ); + + const rsp = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp.status).toBe(204); + }); + + it("handles a CAE challenge where the Base64-encoded claims do not have the correct padding", async () => { + const sendRequest = vi + .fn() + // First, send the standard challenge + .mockImplementationOnce(challengeResponse) + // Then send a CAE challenge immediately after. In this case the padding == at the end of the `claims` has been removed + .mockImplementationOnce(async (req) => ({ + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI2MDc3NTk1In0sInhtc19jYWVlcnJvciI6eyJ2YWx1ZSI6IjEwMDEyIn19fQ"`, + }), + request: req, + status: 401, + })) + // Finally, send a successful response, but only after asserting that the token in the Authorization header was obtained by passing the scopes through to the credential. + .mockImplementationOnce( + successfulResponseWith({ expectAuthorizationHeader: "Bearer cae_token" }), + ); + + const rsp = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp.status).toBe(204); + }); + + it("handles a CAE challenge that comes a few requests after the initial request", async () => { + const sendRequest = vi + .fn() + // First, send the standard challenge + .mockImplementationOnce(challengeResponse) + // Then, send a successful response, but only after asserting that the token in the Authorization header was obtained + .mockImplementationOnce( + successfulResponseWith({ + expectAuthorizationHeader: "Bearer access_token", + responseBody: "response 1", + }), + ) + // Do that again + .mockImplementationOnce( + successfulResponseWith({ + expectAuthorizationHeader: "Bearer access_token", + responseBody: "response 2", + }), + ) + // Then provide a CAE challenge + .mockImplementationOnce(caeChallengeResponse) + // After the CAE challenge, send a successful response, but only after asserting that the token in the Authorization header was obtained by passing the claims through to the credential. + .mockImplementationOnce( + successfulResponseWith({ + expectAuthorizationHeader: "Bearer cae_token", + responseBody: "response 3", + }), + ) + // Next request, we should again get the token we got after the CAE challenge. + .mockImplementationOnce( + successfulResponseWith({ + expectAuthorizationHeader: "Bearer cae_token", + responseBody: "response 4", + }), + ); + + // First request will get the standard challenge followed by a 200 + const rsp = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp.status).toBe(200); + expect(rsp.bodyAsText).toBe("response 1"); + + // Making the request a second time should result in a 200 immediately + const rsp2 = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp2.status).toBe(200); + expect(rsp2.bodyAsText).toBe("response 2"); + + // The third request will get a CAE challenge, which will get handled and then we will get another 200. + const rsp3 = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp3.status).toBe(200); + expect(rsp3.bodyAsText).toBe("response 3"); + + // The fourth request should not have any challenge to handle and we will ultimately get a 200 status. + const rsp4 = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp4.status).toBe(200); + expect(rsp4.bodyAsText).toBe("response 4"); + }); + + it("does not handle multiple consecutive CAE challenges", async () => { + const sendRequest = vi + .fn() + // First, send the standard challenge + .mockImplementationOnce(challengeResponse) + // Then provide a CAE challenge + .mockImplementationOnce(caeChallengeResponse) + // Then another CAE challenge. This challenge should not be handled + .mockImplementationOnce(async (req) => ({ + headers: createHttpHeaders({ + "WWW-Authenticate": caeChallenge, + }), + request: req, + bodyAsText: "CAE challenge 2", + status: 401, + })); + + // Should be a 401 + const rsp = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp.status).toBe(401); + expect(rsp.bodyAsText).toBe("CAE challenge 2"); + }); + + it("subsequent calls to getToken do not have claims after the initial call to CAE getToken", async () => { + vi.useFakeTimers(); + + const sendRequest = vi + .fn() + .mockImplementationOnce(challengeResponse) + .mockImplementationOnce(caeChallengeResponse) + .mockImplementationOnce( + successfulResponseWith({ expectAuthorizationHeader: "Bearer cae_token" }), + ) + // This response will happen after we advance the system clock. Another call to getToken will be made. This + // call should not have `claims` set, and so the token retrieved from the mock credential will be access_token and not cae_token + .mockImplementationOnce( + successfulResponseWith({ + expectAuthorizationHeader: "Bearer access_token", + responseBody: "response 2", + }), + ); + + const rsp1 = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp1.status).toBe(204); + + // Update system time to force token expiry + vi.setSystemTime(new Date().getTime() + TOKEN_EXPIRY); + const rsp2 = await pipeline.sendRequest({ sendRequest }, request); + expect(rsp2.status).toBe(200); + expect(rsp2.bodyAsText).toBe("response 2"); + + vi.useRealTimers(); + }); + + describe("parseWWWAuthenticateHeader tests", () => { + it("Should work for known shapes of the WWW-Authenticate header", () => { + const wwwAuthenticate1 = `Bearer authorization="https://login.windows.net", resource="https://some.url"`; + const parsed1 = parseWWWAuthenticateHeader(wwwAuthenticate1); + expect(parsed1).to.deep.equal({ + authorization: "https://login.windows.net", + resource: "https://some.url", + }); + + const wwwAuthenticate2 = `Bearer authorization="https://login.windows.net/", scope="https://some.url"`; + const parsed2 = parseWWWAuthenticateHeader(wwwAuthenticate2); + expect(parsed2).to.deep.equal({ + authorization: "https://login.windows.net/", + scope: "https://some.url", + }); + }); + + it("Should ignore unknown values in the WWW-Authenticate header", () => { + const wwwAuthenticate1 = `Bearer authorization="https://login.windows.net", resource="https://some.url" scope="scope", a="a", b="b"`; + const parsed1 = parseWWWAuthenticateHeader(wwwAuthenticate1); + expect(parsed1).to.deep.equal({ + authorization: "https://login.windows.net", + resource: "https://some.url", + scope: "scope", + }); + }); + + it("should include the tenantId when present", () => { + const wwwAuthenticate1 = `Bearer authorization="https://login.windows.net/9999", resource="https://some.url"`; + const parsed1 = parseWWWAuthenticateHeader(wwwAuthenticate1); + expect(parsed1).to.deep.equal({ + authorization: "https://login.windows.net/9999", + resource: "https://some.url", + tenantId: "9999", + }); + }); + + it("should handle Base64-encoded claims", () => { + const header = `Bearer claims="SGVsbG8=", error="insufficient_claims"`; + const parsed = parseWWWAuthenticateHeader(header); + expect(parsed).to.deep.equal({ + claims: "SGVsbG8=", + error: "insufficient_claims", + }); + }); + }); +}); diff --git a/sdk/keyvault/keyvault-keys/CHANGELOG.md b/sdk/keyvault/keyvault-keys/CHANGELOG.md index abf3b0006608..193a62c40a18 100644 --- a/sdk/keyvault/keyvault-keys/CHANGELOG.md +++ b/sdk/keyvault/keyvault-keys/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 4.8.1 (Unreleased) +## 4.9.0 (Unreleased) ### Features Added +- Add support for Continuous Access Evaluation (CAE). [#31140](https://github.com/Azure/azure-sdk-for-js/pull/31140) + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/keyvault/keyvault-keys/package.json b/sdk/keyvault/keyvault-keys/package.json index 7f7381da92dc..610475bca866 100644 --- a/sdk/keyvault/keyvault-keys/package.json +++ b/sdk/keyvault/keyvault-keys/package.json @@ -2,7 +2,7 @@ "name": "@azure/keyvault-keys", "sdk-type": "client", "author": "Microsoft Corporation", - "version": "4.8.1", + "version": "4.9.0", "license": "MIT", "description": "Isomorphic client library for Azure KeyVault's keys.", "homepage": "https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/keyvault/keyvault-keys/README.md", @@ -111,7 +111,7 @@ "@azure/core-rest-pipeline": "^1.8.1", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.0.0", - "@azure/keyvault-common": "^1.0.0", + "@azure/keyvault-common": "^2.0.0", "@azure/logger": "^1.0.0", "tslib": "^2.2.0" }, diff --git a/sdk/keyvault/keyvault-keys/src/constants.ts b/sdk/keyvault/keyvault-keys/src/constants.ts index 59040838a911..6d660debf3a8 100644 --- a/sdk/keyvault/keyvault-keys/src/constants.ts +++ b/sdk/keyvault/keyvault-keys/src/constants.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export const SDK_VERSION: string = "4.8.1"; +export const SDK_VERSION: string = "4.9.0"; diff --git a/sdk/keyvault/keyvault-keys/src/cryptography/remoteCryptographyProvider.ts b/sdk/keyvault/keyvault-keys/src/cryptography/remoteCryptographyProvider.ts index 23ece08aba6a..db7e641a96c8 100644 --- a/sdk/keyvault/keyvault-keys/src/cryptography/remoteCryptographyProvider.ts +++ b/sdk/keyvault/keyvault-keys/src/cryptography/remoteCryptographyProvider.ts @@ -2,7 +2,6 @@ // Licensed under the MIT License. import { TokenCredential } from "@azure/core-auth"; -import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; import { DecryptOptions, @@ -34,7 +33,7 @@ import { getKeyFromKeyBundle } from "../transformations"; import { createHash } from "./crypto"; import { CryptographyProvider, CryptographyProviderOperation } from "./models"; import { logger } from "../log"; -import { createKeyVaultChallengeCallbacks } from "@azure/keyvault-common"; +import { keyVaultAuthenticationPolicy } from "@azure/keyvault-common"; import { tracingClient } from "../tracing"; /** @@ -382,12 +381,6 @@ function getOrInitializeClient( : libInfo, }; - const authPolicy = bearerTokenAuthenticationPolicy({ - credential, - scopes: [], // Scopes are going to be defined by the challenge callbacks. - challengeCallbacks: createKeyVaultChallengeCallbacks(options), - }); - const internalPipelineOptions = { ...options, loggingOptions: { @@ -404,7 +397,12 @@ function getOrInitializeClient( options.serviceVersion || LATEST_API_VERSION, internalPipelineOptions, ); - client.pipeline.addPolicy(authPolicy); + + // The authentication policy must come after the deserialization policy since the deserialization policy + // converts 401 responses to an Error, and we don't want to deal with that. + client.pipeline.addPolicy(keyVaultAuthenticationPolicy(credential, options), { + afterPolicies: ["deserializationPolicy"], + }); return client; } diff --git a/sdk/keyvault/keyvault-keys/src/generated/keyVaultClient.ts b/sdk/keyvault/keyvault-keys/src/generated/keyVaultClient.ts index 5b616babff38..886842f9ac0c 100644 --- a/sdk/keyvault/keyvault-keys/src/generated/keyVaultClient.ts +++ b/sdk/keyvault/keyvault-keys/src/generated/keyVaultClient.ts @@ -98,7 +98,7 @@ export class KeyVaultClient extends coreHttpCompat.ExtendedServiceClient { requestContentType: "application/json; charset=utf-8" }; - const packageDetails = `azsdk-js-keyvault-keys/4.8.1`; + const packageDetails = `azsdk-js-keyvault-keys/4.9.0`; const userAgentPrefix = options.userAgentOptions && options.userAgentOptions.userAgentPrefix ? `${options.userAgentOptions.userAgentPrefix} ${packageDetails}` diff --git a/sdk/keyvault/keyvault-keys/src/index.ts b/sdk/keyvault/keyvault-keys/src/index.ts index 82449bf42554..3a6aaba78d72 100644 --- a/sdk/keyvault/keyvault-keys/src/index.ts +++ b/sdk/keyvault/keyvault-keys/src/index.ts @@ -2,8 +2,6 @@ // Licensed under the MIT License. /// -import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; - import { TokenCredential } from "@azure/core-auth"; import { logger } from "./log"; @@ -19,7 +17,7 @@ import { } from "./generated/models"; import { KeyVaultClient } from "./generated/keyVaultClient"; import { SDK_VERSION } from "./constants"; -import { createKeyVaultChallengeCallbacks } from "@azure/keyvault-common"; +import { keyVaultAuthenticationPolicy } from "@azure/keyvault-common"; import { DeleteKeyPoller } from "./lro/delete/poller"; import { RecoverDeletedKeyPoller } from "./lro/recover/poller"; @@ -260,12 +258,6 @@ export class KeyClient { : libInfo, }; - const authPolicy = bearerTokenAuthenticationPolicy({ - credential, - scopes: [], // Scopes are going to be defined by the challenge callbacks. - challengeCallbacks: createKeyVaultChallengeCallbacks(pipelineOptions), - }); - const internalPipelineOptions = { ...pipelineOptions, loggingOptions: { @@ -283,7 +275,12 @@ export class KeyClient { pipelineOptions.serviceVersion || LATEST_API_VERSION, internalPipelineOptions, ); - this.client.pipeline.addPolicy(authPolicy); + + // The authentication policy must come after the deserialization policy since the deserialization policy + // converts 401 responses to an Error, and we don't want to deal with that. + this.client.pipeline.addPolicy(keyVaultAuthenticationPolicy(credential, pipelineOptions), { + afterPolicies: ["deserializationPolicy"], + }); } /** diff --git a/sdk/keyvault/keyvault-keys/swagger/README.md b/sdk/keyvault/keyvault-keys/swagger/README.md index 77120b51efa5..225c8d8a8522 100644 --- a/sdk/keyvault/keyvault-keys/swagger/README.md +++ b/sdk/keyvault/keyvault-keys/swagger/README.md @@ -15,7 +15,7 @@ output-folder: ../ source-code-folder-path: ./src/generated disable-async-iterators: true api-version-parameter: choice -package-version: 4.8.1 +package-version: 4.9.0 use-extension: "@autorest/typescript": "6.0.0-beta.19" ``` diff --git a/sdk/keyvault/keyvault-secrets/CHANGELOG.md b/sdk/keyvault/keyvault-secrets/CHANGELOG.md index 94971bb66a5c..8b5e39936207 100644 --- a/sdk/keyvault/keyvault-secrets/CHANGELOG.md +++ b/sdk/keyvault/keyvault-secrets/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 4.8.1 (Unreleased) +## 4.9.0 (Unreleased) ### Features Added +- Add support for Continuous Access Evaluation (CAE). [#31140](https://github.com/Azure/azure-sdk-for-js/pull/31140) + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/keyvault/keyvault-secrets/package.json b/sdk/keyvault/keyvault-secrets/package.json index 381fe6d419b0..34dda6076189 100644 --- a/sdk/keyvault/keyvault-secrets/package.json +++ b/sdk/keyvault/keyvault-secrets/package.json @@ -2,7 +2,7 @@ "name": "@azure/keyvault-secrets", "sdk-type": "client", "author": "Microsoft Corporation", - "version": "4.8.1", + "version": "4.9.0", "license": "MIT", "description": "Isomorphic client library for Azure KeyVault's secrets.", "homepage": "https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/keyvault/keyvault-secrets/README.md", @@ -102,7 +102,7 @@ "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.0.0", - "@azure/keyvault-common": "^1.0.0", + "@azure/keyvault-common": "^2.0.0", "@azure/logger": "^1.0.0", "tslib": "^2.2.0" }, diff --git a/sdk/keyvault/keyvault-secrets/src/constants.ts b/sdk/keyvault/keyvault-secrets/src/constants.ts index 59040838a911..6d660debf3a8 100644 --- a/sdk/keyvault/keyvault-secrets/src/constants.ts +++ b/sdk/keyvault/keyvault-secrets/src/constants.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export const SDK_VERSION: string = "4.8.1"; +export const SDK_VERSION: string = "4.9.0"; diff --git a/sdk/keyvault/keyvault-secrets/src/generated/keyVaultClient.ts b/sdk/keyvault/keyvault-secrets/src/generated/keyVaultClient.ts index 780dbb27376f..2d89a052c0bf 100644 --- a/sdk/keyvault/keyvault-secrets/src/generated/keyVaultClient.ts +++ b/sdk/keyvault/keyvault-secrets/src/generated/keyVaultClient.ts @@ -70,7 +70,7 @@ export class KeyVaultClient extends coreHttpCompat.ExtendedServiceClient { requestContentType: "application/json; charset=utf-8" }; - const packageDetails = `azsdk-js-keyvault-secrets/4.8.1`; + const packageDetails = `azsdk-js-keyvault-secrets/4.9.0`; const userAgentPrefix = options.userAgentOptions && options.userAgentOptions.userAgentPrefix ? `${options.userAgentOptions.userAgentPrefix} ${packageDetails}` diff --git a/sdk/keyvault/keyvault-secrets/src/index.ts b/sdk/keyvault/keyvault-secrets/src/index.ts index 7739e64f20ac..b458bda27c2f 100644 --- a/sdk/keyvault/keyvault-secrets/src/index.ts +++ b/sdk/keyvault/keyvault-secrets/src/index.ts @@ -4,8 +4,6 @@ import { TokenCredential } from "@azure/core-auth"; -import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; - import { logger } from "./log.js"; import { PageSettings, PagedAsyncIterableIterator } from "@azure/core-paging"; @@ -18,7 +16,7 @@ import { SecretBundle, } from "./generated/models/index.js"; import { KeyVaultClient } from "./generated/keyVaultClient.js"; -import { createKeyVaultChallengeCallbacks } from "@azure/keyvault-common"; +import { keyVaultAuthenticationPolicy } from "@azure/keyvault-common"; import { DeleteSecretPoller } from "./lro/delete/poller.js"; import { RecoverDeletedSecretPoller } from "./lro/recover/poller.js"; @@ -119,12 +117,6 @@ export class SecretClient { ) { this.vaultUrl = vaultUrl; - const authPolicy = bearerTokenAuthenticationPolicy({ - credential, - scopes: [], - challengeCallbacks: createKeyVaultChallengeCallbacks(pipelineOptions), - }); - const internalPipelineOptions = { ...pipelineOptions, loggingOptions: { @@ -141,7 +133,12 @@ export class SecretClient { pipelineOptions.serviceVersion || LATEST_API_VERSION, internalPipelineOptions, ); - this.client.pipeline.addPolicy(authPolicy); + + // The authentication policy must come after the deserialization policy since the deserialization policy + // converts 401 responses to an Error, and we don't want to deal with that. + this.client.pipeline.addPolicy(keyVaultAuthenticationPolicy(credential, pipelineOptions), { + afterPolicies: ["deserializationPolicy"], + }); } /** diff --git a/sdk/keyvault/keyvault-secrets/swagger/README.md b/sdk/keyvault/keyvault-secrets/swagger/README.md index c08b518a860e..f1aa9b78be22 100644 --- a/sdk/keyvault/keyvault-secrets/swagger/README.md +++ b/sdk/keyvault/keyvault-secrets/swagger/README.md @@ -20,5 +20,5 @@ input-file: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/7452e1c output-folder: ../ source-code-folder-path: ./src/generated hide-clients: true -package-version: 4.8.1 +package-version: 4.9.0 ```