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 ```