diff --git a/sdk/core/core-client/CHANGELOG.md b/sdk/core/core-client/CHANGELOG.md index c05ec46a0a72..d0b812c9fad1 100644 --- a/sdk/core/core-client/CHANGELOG.md +++ b/sdk/core/core-client/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features Added +- Added a new function `authorizeRequestOnClaimChallenge`, that can be used with the `@azure/core-rest-pipeline`'s `bearerTokenAuthenticationPolicy` to support [Continuous Access Evaluation (CAE) challenges](https://docs.microsoft.com/azure/active-directory/conditional-access/concept-continuous-access-evaluation). + - Call the `bearerTokenAuthenticationPolicy` with the following options: `bearerTokenAuthenticationPolicy({ authorizeRequestOnChallenge: authorizeRequestOnClaimChallenge })`. Once provided, the `bearerTokenAuthenticationPolicy` policy will internally handle Continuous Access Evaluation (CAE) challenges. When it can't complete a challenge it will return the 401 (unauthorized) response from ARM. + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/core/core-client/package.json b/sdk/core/core-client/package.json index 28a7027c75ec..760aa42ff469 100644 --- a/sdk/core/core-client/package.json +++ b/sdk/core/core-client/package.json @@ -74,6 +74,7 @@ "@azure/core-auth": "^1.3.0", "@azure/core-rest-pipeline": "^1.1.0", "@azure/core-tracing": "1.0.0-preview.13", + "@azure/logger": "^1.0.0", "tslib": "^2.2.0" }, "devDependencies": { diff --git a/sdk/core/core-client/review/core-client.api.md b/sdk/core/core-client/review/core-client.api.md index 93e52f8c0ebe..6daab8867548 100644 --- a/sdk/core/core-client/review/core-client.api.md +++ b/sdk/core/core-client/review/core-client.api.md @@ -5,6 +5,7 @@ ```ts import { AbortSignalLike } from '@azure/abort-controller'; +import { AuthorizeRequestOnChallengeOptions } from '@azure/core-rest-pipeline'; import { HttpClient } from '@azure/core-rest-pipeline'; import { HttpMethods } from '@azure/core-rest-pipeline'; import { InternalPipelineOptions } from '@azure/core-rest-pipeline'; @@ -17,6 +18,9 @@ import { PipelineResponse } from '@azure/core-rest-pipeline'; import { TokenCredential } from '@azure/core-auth'; import { TransferProgressEvent } from '@azure/core-rest-pipeline'; +// @public +export function authorizeRequestOnClaimChallenge(onChallengeOptions: AuthorizeRequestOnChallengeOptions): Promise; + // @public (undocumented) export interface BaseMapper { constraints?: MapperConstraints; diff --git a/sdk/core/core-client/src/authorizeRequestOnClaimChallenge.ts b/sdk/core/core-client/src/authorizeRequestOnClaimChallenge.ts new file mode 100644 index 000000000000..45a9be95a649 --- /dev/null +++ b/sdk/core/core-client/src/authorizeRequestOnClaimChallenge.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { GetTokenOptions } from "@azure/core-auth"; +import { AuthorizeRequestOnChallengeOptions } from "@azure/core-rest-pipeline"; +import { createClientLogger } from "@azure/logger"; +import { decodeStringToString } from "./base64"; + +const logger = createClientLogger("authorizeRequestOnClaimChallenge"); + +/** + * Converts: `Bearer a="b", c="d", Bearer d="e", f="g"`. + * Into: `[ { a: 'b', c: 'd' }, { d: 'e', f: 'g' } ]`. + * + * @internal + */ +export function parseCAEChallenge(challenges: string): any[] { + const bearerChallenges = `, ${challenges.trim()}`.split(", Bearer ").filter((x) => x); + return bearerChallenges.map((challenge) => { + const challengeParts = `${challenge.trim()}, `.split('", ').filter((x) => x); + const keyValuePairs = challengeParts.map((keyValue) => + (([key, value]) => ({ [key]: value }))(keyValue.trim().split('="')) + ); + // Key-value pairs to plain object: + return keyValuePairs.reduce((a, b) => ({ ...a, ...b }), {}); + }); +} + +/** + * CAE Challenge structure + */ +export interface CAEChallenge { + scope: string; + claims: string; +} + +/** + * This function can be used as a callback for the `bearerTokenAuthenticationPolicy` of `@azure/core-rest-pipeline`, to support CAE challenges: + * [Continuous Access Evaluation](https://docs.microsoft.com/azure/active-directory/conditional-access/concept-continuous-access-evaluation). + * + * Call the `bearerTokenAuthenticationPolicy` with the following options: + * + * ```ts + * import { bearerTokenAuthenticationPolicy } from "@azure/core-rest-pipeline"; + * import { authorizeRequestOnClaimChallenge } from "@azure/core-client"; + * + * const bearerTokenAuthenticationPolicy = bearerTokenAuthenticationPolicy({ + * authorizeRequestOnChallenge: authorizeRequestOnClaimChallenge + * }); + * ``` + * + * Once provided, the `bearerTokenAuthenticationPolicy` policy will internally handle Continuous Access Evaluation (CAE) challenges. + * When it can't complete a challenge it will return the 401 (unauthorized) response from ARM. + * + * Example challenge with claims: + * + * ``` + * Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token", + * error_description="User session has been revoked", + * claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0=" + * ``` + */ +export async function authorizeRequestOnClaimChallenge( + onChallengeOptions: AuthorizeRequestOnChallengeOptions +): Promise { + const { scopes, response } = onChallengeOptions; + + const challenge = response.headers.get("WWW-Authenticate"); + if (!challenge) { + logger.info( + `The WWW-Authenticate header was missing. Failed to perform the Continuous Access Evaluation authentication flow.` + ); + return false; + } + const challenges: CAEChallenge[] = parseCAEChallenge(challenge) || []; + + const parsedChallenge = challenges.find((x) => x.claims); + if (!parsedChallenge) { + logger.info( + `The WWW-Authenticate header was missing the necessary "claims" to perform the Continuous Access Evaluation authentication flow.` + ); + return false; + } + + const accessToken = await onChallengeOptions.getAccessToken( + parsedChallenge.scope ? [parsedChallenge.scope] : scopes, + { + claims: decodeStringToString(parsedChallenge.claims) + } as GetTokenOptions + ); + + if (!accessToken) { + return false; + } + + onChallengeOptions.request.headers.set("Authorization", `Bearer ${accessToken.token}`); + return true; +} diff --git a/sdk/core/core-client/src/base64.browser.ts b/sdk/core/core-client/src/base64.browser.ts index 992e22121006..70f0fdb02292 100644 --- a/sdk/core/core-client/src/base64.browser.ts +++ b/sdk/core/core-client/src/base64.browser.ts @@ -40,3 +40,11 @@ export function decodeString(value: string): Uint8Array { } return arr; } + +/** + * Decodes a base64 string into a string. + * @param value - the base64 string to decode + */ +export function decodeStringToString(value: string): string { + return atob(value); +} diff --git a/sdk/core/core-client/src/base64.ts b/sdk/core/core-client/src/base64.ts index da8f9cd596ce..7997d9bc7a6d 100644 --- a/sdk/core/core-client/src/base64.ts +++ b/sdk/core/core-client/src/base64.ts @@ -30,3 +30,11 @@ export function encodeByteArray(value: Uint8Array): string { export function decodeString(value: string): Uint8Array { return Buffer.from(value, "base64"); } + +/** + * Decodes a base64 string into a string. + * @param value - the base64 string to decode + */ +export function decodeStringToString(value: string): string { + return Buffer.from(value, "base64").toString(); +} diff --git a/sdk/core/core-client/src/index.ts b/sdk/core/core-client/src/index.ts index de6d200d993b..acdf6b5de3ec 100644 --- a/sdk/core/core-client/src/index.ts +++ b/sdk/core/core-client/src/index.ts @@ -52,4 +52,5 @@ export { serializationPolicyName, SerializationPolicyOptions } from "./serializationPolicy"; +export { authorizeRequestOnClaimChallenge } from "./authorizeRequestOnClaimChallenge"; import "@azure/core-asynciterator-polyfill"; diff --git a/sdk/core/core-client/test/authorizeRequestOnClaimChallenge.spec.ts b/sdk/core/core-client/test/authorizeRequestOnClaimChallenge.spec.ts new file mode 100644 index 000000000000..7dc78df85f47 --- /dev/null +++ b/sdk/core/core-client/test/authorizeRequestOnClaimChallenge.spec.ts @@ -0,0 +1,355 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth"; +import { + bearerTokenAuthenticationPolicy, + createEmptyPipeline, + createHttpHeaders, + createPipelineRequest, + HttpClient, + PipelineResponse +} from "@azure/core-rest-pipeline"; +import { assert } from "chai"; +import { + authorizeRequestOnClaimChallenge, + parseCAEChallenge +} from "../src/authorizeRequestOnClaimChallenge"; +import { encodeString } from "../src/base64"; + +describe("authorizeRequestOnClaimChallenge", function() { + it(`should try to get the access token if the response has a valid claims parameter on the WWW-Authenticate header`, async function() { + const request = createPipelineRequest({ url: "https://example.com" }); + const getAccessTokenParameters: { + scopes: string | string[]; + getTokenOptions: GetTokenOptions; + }[] = []; + + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken(scopes, getTokenOptions) { + getAccessTokenParameters.push({ scopes, getTokenOptions }); + return { + token: "accessToken", + expiresOnTimestamp: new Date().getTime() + }; + }, + scopes: [], + response: { + headers: createHttpHeaders({ + "WWW-Authenticate": [ + `Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token"`, + `error_description="User session has been revoked"`, + `scope="https://endpoint/.default"`, + `claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="` + ].join(", ") + }), + request, + status: 401 + }, + request + }); + + assert.isTrue(result); + + assert.deepEqual(getAccessTokenParameters, [ + { + scopes: ["https://endpoint/.default"], + getTokenOptions: { + claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}' + } as GetTokenOptions + } + ]); + }); + + it(`should try to get the access token with the parametrized scopes if the response has no scope property on the WWW-authenticate header`, async function() { + const request = createPipelineRequest({ url: "https://example.com" }); + const getAccessTokenParameters: { + scopes: string | string[]; + getTokenOptions: GetTokenOptions; + }[] = []; + + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken(scopes, getTokenOptions) { + getAccessTokenParameters.push({ scopes, getTokenOptions }); + return { + token: "accessToken", + expiresOnTimestamp: new Date().getTime() + }; + }, + scopes: ["https://parametrized-endpoint/.default"], + response: { + headers: createHttpHeaders({ + "WWW-Authenticate": [ + `Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token"`, + `error_description="User session has been revoked"`, + `claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="` + ].join(", ") + }), + request, + status: 401 + }, + request + }); + + assert.isTrue(result); + + assert.deepEqual(getAccessTokenParameters, [ + { + scopes: ["https://parametrized-endpoint/.default"], + getTokenOptions: { + claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}' + } as GetTokenOptions + } + ]); + }); + + it(`should work even if the WWW-authenticate header is missing some base64 padding`, async function() { + // In Python, padding has to be added at the end if the size of the base64 string is not a multiple of 4. + // In JavaScript, the padding is added automatically. + + const request = createPipelineRequest({ url: "https://example.com" }); + const getAccessTokenParameters: { + scopes: string | string[]; + getTokenOptions: GetTokenOptions; + }[] = []; + + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken(scopes, getTokenOptions) { + getAccessTokenParameters.push({ scopes, getTokenOptions }); + return { + token: "accessToken", + expiresOnTimestamp: new Date().getTime() + }; + }, + scopes: ["https://parametrized-endpoint/.default"], + response: { + headers: createHttpHeaders({ + "WWW-Authenticate": [ + `Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token"`, + `error_description="User session has been revoked"`, + // Missing `=` at the end. + `claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0"` + ].join(", ") + }), + request, + status: 401 + }, + request + }); + + assert.isTrue(result); + + assert.deepEqual(getAccessTokenParameters, [ + { + scopes: ["https://parametrized-endpoint/.default"], + getTokenOptions: { + claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}' + } as GetTokenOptions + } + ]); + }); + + it(`should return false if getAccessToken is called and if it doesn't return an access token`, async function() { + const request = createPipelineRequest({ url: "https://example.com" }); + const getAccessTokenParameters: { + scopes: string | string[]; + getTokenOptions: GetTokenOptions; + }[] = []; + + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken(scopes, getTokenOptions) { + getAccessTokenParameters.push({ scopes, getTokenOptions }); + return null; + }, + scopes: ["https://parametrized-endpoint/.default"], + response: { + headers: createHttpHeaders({ + "WWW-Authenticate": [ + `Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token"`, + `error_description="User session has been revoked"`, + `claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="` + ].join(", ") + }), + request, + status: 401 + }, + request + }); + + assert.isFalse(result); + + assert.deepEqual(getAccessTokenParameters, [ + { + scopes: ["https://parametrized-endpoint/.default"], + getTokenOptions: { + claims: '{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}' + } as GetTokenOptions + } + ]); + }); + + it(`should return false if the response has an invalid claims parameter on the WWW-Authenticate header`, async function() { + const request = createPipelineRequest({ url: "https://example.com" }); + const getAccessTokenParameters: { + scopes: string | string[]; + getTokenOptions: GetTokenOptions; + }[] = []; + + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken(scopes, getTokenOptions) { + getAccessTokenParameters.push({ scopes, getTokenOptions }); + return null; + }, + scopes: ["https://parametrized-endpoint/.default"], + response: { + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer authorization_uri="https://login.windows-ppe.net/", error="invalid_token"` + }), + request, + status: 401 + }, + request + }); + + assert.isFalse(result); + + assert.deepEqual(getAccessTokenParameters, []); + }); + + it(`should return false if the response has no WWW-Authenticate header`, async function() { + const request = createPipelineRequest({ url: "https://example.com" }); + const getAccessTokenParameters: { + scopes: string | string[]; + getTokenOptions: GetTokenOptions; + }[] = []; + + const result = await authorizeRequestOnClaimChallenge({ + async getAccessToken(scopes, getTokenOptions) { + getAccessTokenParameters.push({ scopes, getTokenOptions }); + return null; + }, + scopes: ["https://parametrized-endpoint/.default"], + response: { + headers: createHttpHeaders({}), + request, + status: 401 + }, + request + }); + + assert.isFalse(result); + + assert.deepEqual(getAccessTokenParameters, []); + }); + + describe("(Internal) parseCAEChallenge", function() { + it("correctly parses a CAE challenge", function() { + const utf8Claims = `Bearer a="b", c="d", Bearer d="e", f="g"`; + const result = parseCAEChallenge(utf8Claims); + assert.deepEqual(result, [ + { a: "b", c: "d" }, + { d: "e", f: "g" } + ]); + }); + }); + + describe("with the bearerTokenAuthenticationPolicy", function() { + class MockRefreshAzureCredential implements TokenCredential { + public authCount = 0; + public scopesAndClaims: { + scope: string | string[]; + challengeClaims: string | undefined; + }[] = []; + public getTokenResponses: (AccessToken | null)[]; + + constructor(getTokenResponses: (AccessToken | null)[]) { + this.getTokenResponses = getTokenResponses; + } + + public getToken( + scope: string | string[], + options: GetTokenOptions & { claims?: string } + ): Promise { + this.authCount++; + this.scopesAndClaims.push({ scope, challengeClaims: options.claims }); + return Promise.resolve(this.getTokenResponses.shift()!); + } + } + + it("tests that the scope and the claim have been passed through to getToken correctly - with @azure/core-client's authorizeRequestOnClaimChallenge", async function() { + const expected = { + scope: ["http://localhost/.default"], + challengeClaims: JSON.stringify({ + access_token: { foo: "bar" } + }) + }; + + const pipelineRequest = createPipelineRequest({ url: "https://example.com" }); + const responses: PipelineResponse[] = [ + { + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer scope="${expected.scope[0]}", claims="${encodeString( + expected.challengeClaims + )}"` + }), + request: pipelineRequest, + status: 401 + }, + { + headers: createHttpHeaders(), + request: pipelineRequest, + status: 200 + } + ]; + + const expiresOn = Date.now() + 5000; + const getTokenResponse = { token: "mock-token", expiresOnTimestamp: expiresOn }; + const credential = new MockRefreshAzureCredential([getTokenResponse]); + + const pipeline = createEmptyPipeline(); + let firstRequest: boolean = true; + const bearerPolicy = bearerTokenAuthenticationPolicy({ + // Intentionally left empty, as it should be replaced by the challenge. + scopes: [], + credential, + challengeCallbacks: { + async authorizeRequest({ request, getAccessToken }) { + if (firstRequest) { + firstRequest = false; + // send first request without the Authorization header + } else { + const token = await getAccessToken([], {}); + request.headers.set("Authorization", `Bearer ${token}`); + } + }, + authorizeRequestOnChallenge: authorizeRequestOnClaimChallenge + } + }); + pipeline.addPolicy(bearerPolicy); + + const finalSendRequestHeaders: (string | undefined)[] = []; + + const testHttpsClient: HttpClient = { + sendRequest: async (req) => { + finalSendRequestHeaders.push(req.headers.get("Authorization")); + if (responses.length) { + const response = responses.shift()!; + response.request = req; + return response; + } + throw new Error("No responses found"); + } + }; + + await pipeline.sendRequest(testHttpsClient, pipelineRequest); + + assert.deepEqual(credential.scopesAndClaims, [ + { + scope: expected.scope, + challengeClaims: expected.challengeClaims + } + ]); + assert.deepEqual(finalSendRequestHeaders, [undefined, `Bearer ${getTokenResponse.token}`]); + }); + }); +});