diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 7d322212e254..0feceabb051a 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -221,6 +221,22 @@ packages: node: '>=8.0.0' resolution: integrity: sha512-his7Ah40ThEYORSpIAwuh6B8wkGwO/zG7gqVtmSE4WAJ46e36zUDXTKReUCLBDc6HmjjApQQxxcRFy5FruG79A== + /@azure/core-rest-pipeline/1.0.3: + dependencies: + '@azure/abort-controller': 1.0.4 + '@azure/core-auth': 1.3.0 + '@azure/core-tracing': 1.0.0-preview.11 + '@azure/logger': 1.0.2 + form-data: 3.0.1 + http-proxy-agent: 4.0.1 + https-proxy-agent: 5.0.0 + tslib: 2.2.0 + uuid: 8.3.2 + dev: false + engines: + node: '>=8.0.0' + resolution: + integrity: sha512-GbfBQHF83RQI+LVISh8RLKpPeyufFsu6FhwB0U1inN7BWo8GuE23s0vc/D4gd5AWww7orQ20Q3zMzW5FKFs4MQ== /@azure/core-tracing/1.0.0-preview.11: dependencies: '@opencensus/web-types': 0.0.7 @@ -7813,6 +7829,7 @@ packages: version: 0.0.0 file:projects/ai-document-translator.tgz: dependencies: + '@azure/core-rest-pipeline': 1.0.3 '@azure/identity': 1.3.0 '@microsoft/api-extractor': 7.13.2 '@types/chai': 4.2.16 @@ -7849,7 +7866,7 @@ packages: dev: false name: '@rush-temp/ai-document-translator' resolution: - integrity: sha512-dKk6kAyDxOTFMIn74Biij9flT5ae7+6AWKzuI9N5yJNEPWzXGHUndSYjFSt7O5/UsLClgfelyiqY0dAKxOiDLw== + integrity: sha512-9CiuexeDJhhLy+YHIW1xYM/AxrPheSh42xbKH/Kaz1gFy9LUU0GxnD1TmbxAb9gbfridggKjlpvaUATqZy5cBQ== tarball: file:projects/ai-document-translator.tgz version: 0.0.0 file:projects/ai-form-recognizer.tgz: @@ -7943,6 +7960,7 @@ packages: version: 0.0.0 file:projects/ai-text-analytics.tgz: dependencies: + '@azure/core-rest-pipeline': 1.0.3 '@azure/core-tracing': 1.0.0-preview.11 '@azure/identity': 1.3.0 '@microsoft/api-extractor': 7.7.11 @@ -7985,7 +8003,7 @@ packages: dev: false name: '@rush-temp/ai-text-analytics' resolution: - integrity: sha512-99pEvJ4z5j42gxikMrqfl5owp5uDGFuNT9v5oVg2snIYHdmSNeYAawsMU4+WZD39+/c+EnInUT8fAn8mc5NvsA== + integrity: sha512-lCqIX/6MmgfuTkCZ/iu21NuBgHPUGgrfNJBu8mJjtFQYVNegNOhd2MoBqVnUz7rp5p1fnI17lBsHVS2TbsrmQw== tarball: file:projects/ai-text-analytics.tgz version: 0.0.0 file:projects/app-configuration.tgz: @@ -8363,6 +8381,7 @@ packages: file:projects/container-registry.tgz: dependencies: '@azure/arm-containerregistry': 8.0.0 + '@azure/core-rest-pipeline': 1.0.3 '@azure/core-tracing': 1.0.0-preview.11 '@azure/identity': 1.3.0 '@azure/ms-rest-nodeauth': 3.0.9 @@ -8403,7 +8422,7 @@ packages: dev: false name: '@rush-temp/container-registry' resolution: - integrity: sha512-SsPsb2h7leU8zPAUjD+0e1wte3wUfwm+C8Hph+HIElBIROIv0pJsZGtM6pQIw2Bza3r39oegtO3TY3fAxJR8/g== + integrity: sha512-JOd4JDWNPmckvkuO+IOmhWrO/9Rbp44M6Bm7VHySweQEavSxxMk+iAS7akisWRQfGhttzYaQ0jc4Py7rNmWF6A== tarball: file:projects/container-registry.tgz version: 0.0.0 file:projects/core-amqp.tgz: @@ -8514,6 +8533,7 @@ packages: version: 0.0.0 file:projects/core-client-1.tgz: dependencies: + '@azure/core-rest-pipeline': 1.0.3 '@azure/core-tracing': 1.0.0-preview.11 '@azure/core-xml': 1.0.0-beta.1 '@microsoft/api-extractor': 7.7.11 @@ -8558,11 +8578,12 @@ packages: dev: false name: '@rush-temp/core-client-1' resolution: - integrity: sha512-21H/GvL5vjCaXYQD8ETNkbn4a2s35EY9ohVTqK7bR996ytd0gTX1XCcwlbLrnPFJpJD22X2oCZBmwx3tUDsJag== + integrity: sha512-5Yty3M7abL/LT12Huh21EOQUOR941Dw7IKn61TSC/wZ8YFRofUFshxtzw2Xmy2a7n9Z48uhhmA5tF62T9jQivg== tarball: file:projects/core-client-1.tgz version: 0.0.0 file:projects/core-client.tgz: dependencies: + '@azure/core-rest-pipeline': 1.0.3 '@azure/core-tracing': 1.0.0-preview.11 '@azure/core-xml': 1.0.0-beta.1 '@microsoft/api-extractor': 7.13.2 @@ -8607,7 +8628,7 @@ packages: dev: false name: '@rush-temp/core-client' resolution: - integrity: sha512-ysfQQBePlUwpeDJmTUCRqYxf/zHdH+UTQ+EAByVTDOvM/2ekmsXjwhJIBwp78vBnQURbtNjIuSFYKk2od3amBg== + integrity: sha512-aBpAf3CuDzIG06arRUUV2je24ego02pD1CQhpaNQ7A+XGVtGydPmc/E+5iloRBHDyogM217pE1TDfn0Boi0kKg== tarball: file:projects/core-client.tgz version: 0.0.0 file:projects/core-crypto.tgz: @@ -9027,6 +9048,7 @@ packages: version: 0.0.0 file:projects/data-tables.tgz: dependencies: + '@azure/core-rest-pipeline': 1.0.3 '@azure/core-tracing': 1.0.0-preview.11 '@azure/core-xml': 1.0.0-beta.1 '@microsoft/api-extractor': 7.7.11 @@ -9078,7 +9100,7 @@ packages: dev: false name: '@rush-temp/data-tables' resolution: - integrity: sha512-9hK/ry3jr4Vfm2IUotAKYubi8mN1LaPqz//ggrWAyc1rSyMjOjNAkDnoCFWZkL99NdYXrM8wxcXlOOmuRlynmw== + integrity: sha512-IDeu2YShGePIGGHY6J4UBw930fwmpgbV/fhZylLZnt9FApLDhQLX5FCWaTOWj0EuncfwstJAYQCKzlxaXigl9w== tarball: file:projects/data-tables.tgz version: 0.0.0 file:projects/dev-tool.tgz: @@ -9336,6 +9358,7 @@ packages: version: 0.0.0 file:projects/eventgrid.tgz: dependencies: + '@azure/core-rest-pipeline': 1.0.3 '@azure/core-tracing': 1.0.0-preview.11 '@azure/service-bus': 7.0.5 '@microsoft/api-extractor': 7.7.11 @@ -9388,7 +9411,7 @@ packages: dev: false name: '@rush-temp/eventgrid' resolution: - integrity: sha512-xh6kgtOlv01x9G/APg7o96Or8mejiJNCTIzIPGfOhGuQ3cJdkgmDt4BIRvcwNHlaLarZ4v+ytKhAw0JCj122cQ== + integrity: sha512-TrRicEN8OEh7DFa/GvPh/oxfL821p9Yb/WQM79S5WjPuJwXCyWYemMDTqn17up550TkO98CxvBQBpzVNNFXdNA== tarball: file:projects/eventgrid.tgz version: 0.0.0 file:projects/eventhubs-checkpointstore-blob.tgz: @@ -10067,6 +10090,7 @@ packages: version: 0.0.0 file:projects/perf-storage-blob.tgz: dependencies: + '@azure/core-rest-pipeline': 1.0.3 '@types/node': 8.10.66 '@types/node-fetch': 2.5.10 '@types/uuid': 8.3.0 @@ -10082,7 +10106,7 @@ packages: dev: false name: '@rush-temp/perf-storage-blob' resolution: - integrity: sha512-CQP5Lrw+CR+EpibLFTir3/fZn3oIwh0UG1C9JsKtKkzfGaw0BO2V8vV5FoKCp4RwiEhbkvkHnu89DuBZMxZH7A== + integrity: sha512-AzR94ZYGDCm+pUJmj/lKEUOYlzhFxEyl4W0sotw3M1j/zSqwMYbvNJTwg1rQf2/4SjhFkfQdj+boXbsVJ3gNaQ== tarball: file:projects/perf-storage-blob.tgz version: 0.0.0 file:projects/perf-storage-file-datalake.tgz: @@ -10473,6 +10497,7 @@ packages: version: 0.0.0 file:projects/storage-blob.tgz: dependencies: + '@azure/core-rest-pipeline': 1.0.3 '@azure/core-tracing': 1.0.0-preview.11 '@azure/identity': 1.3.0 '@microsoft/api-extractor': 7.7.11 @@ -10527,7 +10552,7 @@ packages: dev: false name: '@rush-temp/storage-blob' resolution: - integrity: sha512-zjsBvTJeN5+OX5qmry75Z71EW/mgT8oSGLlNlvbvjsjF41/so6m4oNpwBpQIw6oOp5vpqtV5G07Sh8u1vEQmnw== + integrity: sha512-CR55ZzmPi/l7XwTZdOY2mvJU2OF6OL6aloG8wLv+pLaljwm2uf600JhMvgD5PzCh3d4/jtctZtsNq8WDUwgrng== tarball: file:projects/storage-blob.tgz version: 0.0.0 file:projects/storage-file-datalake.tgz: diff --git a/sdk/core/core-rest-pipeline/CHANGELOG.md b/sdk/core/core-rest-pipeline/CHANGELOG.md index 6dafc7b7338b..35d470cef1fb 100644 --- a/sdk/core/core-rest-pipeline/CHANGELOG.md +++ b/sdk/core/core-rest-pipeline/CHANGELOG.md @@ -1,8 +1,10 @@ # Release History -## 1.0.4 (Unreleased) +## 1.1.0-beta.1 (Unreleased) -- Rewrote `bearerTokenAuthenticationPolicy` to use a new backend that refreshes tokens only when they're about to expire and not multiple times before. This is based on a similar fix implemented on `@azure/core-http@1.2.4` ([PR with the changes](https://github.com/Azure/azure-sdk-for-js/pull/14223)). This fixes the issue: [13369](https://github.com/Azure/azure-sdk-for-js/issues/13369). +- Add a new `bearerTokenChallengeAuthenticationPolicy` that provides a skeleton of handling challenge-based authorization. There are two extensible points: `authorizeRequest` and `authorizeRequestOnChallenge` callbacks. + - `authorizeRequest` allows customizing the policy to alter how it authorizes a request before sending it. By default when no callbacks are specified, this policy has the same behavior as `bearerTokenAuthenticationPolicy`. It will retrieve the token from the underlying token credential, and if it gets one, it will cache the token and set it to the outgoing request. + - `authorizeRequestOnChallenge`, which gets called only if we've found a challenge in the response. This callback has access to the original request and its response and is expected to handle the challenge. If this callback returns true, the request, usually updated after handling the challenge, will be sent again. If this call back returns false, no further actions will be taken. ## 1.0.3 (2021-03-30) diff --git a/sdk/core/core-rest-pipeline/package.json b/sdk/core/core-rest-pipeline/package.json index 7b6ffe49180c..8b9901a8a5d2 100644 --- a/sdk/core/core-rest-pipeline/package.json +++ b/sdk/core/core-rest-pipeline/package.json @@ -1,6 +1,6 @@ { "name": "@azure/core-rest-pipeline", - "version": "1.0.4", + "version": "1.1.0-beta.1", "description": "Isomorphic client library for making HTTP requests in node.js and browser.", "sdk-type": "client", "main": "dist/index.js", diff --git a/sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md b/sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md index 46b715b42be6..dd654a42b0df 100644 --- a/sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md +++ b/sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md @@ -5,7 +5,9 @@ ```ts import { AbortSignalLike } from '@azure/abort-controller'; +import { AccessToken } from '@azure/core-auth'; import { Debugger } from '@azure/logger'; +import { GetTokenOptions } from '@azure/core-auth'; import { OperationTracingOptions } from '@azure/core-tracing'; import { TokenCredential } from '@azure/core-auth'; @@ -26,6 +28,21 @@ export interface Agent { sockets: unknown; } +// @public +export interface AuthorizeRequestOnChallengeOptions { + getAccessToken: (scopes: string[], options: GetTokenOptions) => Promise; + request: PipelineRequest; + response: PipelineResponse; + scopes: string[]; +} + +// @public +export interface AuthorizeRequestOptions { + getAccessToken: (scopes: string[], options: GetTokenOptions) => Promise; + request: PipelineRequest; + scopes: string[]; +} + // @public export function bearerTokenAuthenticationPolicy(options: BearerTokenAuthenticationPolicyOptions): PipelinePolicy; @@ -38,6 +55,25 @@ export interface BearerTokenAuthenticationPolicyOptions { scopes: string | string[]; } +// @public +export function bearerTokenChallengeAuthenticationPolicy(options: BearerTokenChallengeAuthenticationPolicyOptions): PipelinePolicy; + +// @public +export const bearerTokenChallengeAuthenticationPolicyName = "bearerTokenChallengeAuthenticationPolicy"; + +// @public +export interface BearerTokenChallengeAuthenticationPolicyOptions { + challengeCallbacks?: ChallengeCallbacks; + credential: TokenCredential; + scopes: string[]; +} + +// @public +export interface ChallengeCallbacks { + authorizeRequest?(options: AuthorizeRequestOptions): Promise; + authorizeRequestOnChallenge?(options: AuthorizeRequestOnChallengeOptions): Promise; +} + // @public export function createDefaultHttpClient(): HttpClient; diff --git a/sdk/core/core-rest-pipeline/src/constants.ts b/sdk/core/core-rest-pipeline/src/constants.ts index 13feb71717f6..188a3cc82149 100644 --- a/sdk/core/core-rest-pipeline/src/constants.ts +++ b/sdk/core/core-rest-pipeline/src/constants.ts @@ -1,4 +1,4 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export const SDK_VERSION: string = "1.0.4"; +export const SDK_VERSION: string = "1.1.0-beta.1"; diff --git a/sdk/core/core-rest-pipeline/src/index.ts b/sdk/core/core-rest-pipeline/src/index.ts index 25324b8fdfbc..bfeb13ee0002 100644 --- a/sdk/core/core-rest-pipeline/src/index.ts +++ b/sdk/core/core-rest-pipeline/src/index.ts @@ -68,4 +68,12 @@ export { BearerTokenAuthenticationPolicyOptions, bearerTokenAuthenticationPolicyName } from "./policies/bearerTokenAuthenticationPolicy"; +export { + bearerTokenChallengeAuthenticationPolicy, + BearerTokenChallengeAuthenticationPolicyOptions, + bearerTokenChallengeAuthenticationPolicyName, + ChallengeCallbacks, + AuthorizeRequestOptions, + AuthorizeRequestOnChallengeOptions +} from "./policies/bearerTokenChallengeAuthenticationPolicy"; export { ndJsonPolicy, ndJsonPolicyName } from "./policies/ndJsonPolicy"; diff --git a/sdk/core/core-rest-pipeline/src/policies/bearerTokenAuthenticationPolicy.ts b/sdk/core/core-rest-pipeline/src/policies/bearerTokenAuthenticationPolicy.ts index 927a87539c74..d19519bb0bb4 100644 --- a/sdk/core/core-rest-pipeline/src/policies/bearerTokenAuthenticationPolicy.ts +++ b/sdk/core/core-rest-pipeline/src/policies/bearerTokenAuthenticationPolicy.ts @@ -38,12 +38,12 @@ export function bearerTokenAuthenticationPolicy( // The options are left out of the public API until there's demand to configure this. // Remember to extend `BearerTokenAuthenticationPolicyOptions` with `TokenCyclerOptions` // in order to pass through the `options` object. - const getToken = createTokenCycler(credential, scopes /* , options */); + const cycler = createTokenCycler(credential /* , options */); return { name: bearerTokenAuthenticationPolicyName, async sendRequest(request: PipelineRequest, next: SendRequest): Promise { - const { token } = await getToken({ + const { token } = await cycler.getToken(scopes, { abortSignal: request.abortSignal, tracingOptions: request.tracingOptions }); diff --git a/sdk/core/core-rest-pipeline/src/policies/bearerTokenChallengeAuthenticationPolicy.ts b/sdk/core/core-rest-pipeline/src/policies/bearerTokenChallengeAuthenticationPolicy.ts new file mode 100644 index 000000000000..02f0b76320ab --- /dev/null +++ b/sdk/core/core-rest-pipeline/src/policies/bearerTokenChallengeAuthenticationPolicy.ts @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { TokenCredential, GetTokenOptions, AccessToken } from "@azure/core-auth"; +import { PipelineResponse, PipelineRequest, SendRequest } from "../interfaces"; +import { PipelinePolicy } from "../pipeline"; +import { createTokenCycler } from "../util/tokenCycler"; + +/** + * The programmatic identifier of the bearerTokenChallengeAuthenticationPolicy. + */ +export const bearerTokenChallengeAuthenticationPolicyName = + "bearerTokenChallengeAuthenticationPolicy"; + +/** + * Options sent to the authorizeRequest callback + */ +export interface AuthorizeRequestOptions { + /** + * The scopes for which the bearer token applies. + */ + scopes: string[]; + /** + * Function that retrieves either a cached access token or a new access token. + */ + getAccessToken: (scopes: string[], options: GetTokenOptions) => Promise; + /** + * Request that the policy is trying to fulfill. + */ + request: PipelineRequest; +} + +/** + * Options sent to the authorizeRequestOnChallenge callback + */ +export interface AuthorizeRequestOnChallengeOptions { + /** + * The scopes for which the bearer token applies. + */ + scopes: string[]; + /** + * Function that retrieves either a cached access token or a new access token. + */ + getAccessToken: (scopes: string[], options: GetTokenOptions) => Promise; + /** + * Request that the policy is trying to fulfill. + */ + request: PipelineRequest; + /** + * Response containing the challenge. + */ + response: PipelineResponse; +} + +/** + * Options to override the processing of [Continuous Access Evaluation](https://docs.microsoft.com/azure/active-directory/conditional-access/concept-continuous-access-evaluation) challenges. + */ +export interface ChallengeCallbacks { + /** + * Allows for the authorization of the main request of this policy before it's sent. + */ + authorizeRequest?(options: AuthorizeRequestOptions): Promise; + /** + * Allows to handle authentication challenges and to re-authorize the request. + * The response containing the challenge is `options.response`. + * If this method returns true, the underlying request will be sent once again. + * The request may be modified before being sent. + */ + authorizeRequestOnChallenge?(options: AuthorizeRequestOnChallengeOptions): Promise; +} + +/** + * Options to configure the bearerTokenChallengeAuthenticationPolicy + */ +export interface BearerTokenChallengeAuthenticationPolicyOptions { + /** + * The TokenCredential implementation that can supply the bearer token. + */ + credential: TokenCredential; + /** + * The scopes for which the bearer token applies. + */ + scopes: string[]; + /** + * Allows for the processing of [Continuous Access Evaluation](https://docs.microsoft.com/azure/active-directory/conditional-access/concept-continuous-access-evaluation) challenges. + * If provided, it must contain at least the `authorizeRequestOnChallenge` method. + * If provided, after a request is sent, if it has a challenge, it can be processed to re-send the original request with the relevant challenge information. + */ + challengeCallbacks?: ChallengeCallbacks; +} + +/** + * Default authorize request handler + */ +async function defaultAuthorizeRequest(options: AuthorizeRequestOptions): Promise { + const { scopes, getAccessToken, request } = options; + const getTokenOptions: GetTokenOptions = { + abortSignal: request.abortSignal, + tracingOptions: request.tracingOptions + }; + const accessToken = await getAccessToken(scopes, getTokenOptions); + + if (accessToken) { + options.request.headers.set("Authorization", `Bearer ${accessToken.token}`); + } +} + +/** + * We will retrieve the challenge only if the response status code was 401, + * and if the response contained the header "WWW-Authenticate" with a non-empty value. + */ +function getChallenge(response: PipelineResponse): string | undefined { + const challenge = response.headers.get("WWW-Authenticate"); + if (response.status === 401 && challenge) { + return challenge; + } + return; +} + +/** + * A policy that can request a token from a TokenCredential implementation and + * then apply it to the Authorization header of a request as a Bearer token. + */ +export function bearerTokenChallengeAuthenticationPolicy( + options: BearerTokenChallengeAuthenticationPolicyOptions +): PipelinePolicy { + const { credential, scopes, challengeCallbacks } = options; + const callbacks = { + authorizeRequest: challengeCallbacks?.authorizeRequest ?? defaultAuthorizeRequest, + authorizeRequestOnChallenge: challengeCallbacks?.authorizeRequestOnChallenge, + // keep all other properties + ...challengeCallbacks + }; + + // This function encapsulates the entire process of reliably retrieving the token + // The options are left out of the public API until there's demand to configure this. + // Remember to extend `BearerTokenChallengeAuthenticationPolicyOptions` with `TokenCyclerOptions` + // in order to pass through the `options` object. + const cycler = createTokenCycler(credential /* , options */); + + return { + name: bearerTokenChallengeAuthenticationPolicyName, + /** + * If there's no challenge parameter: + * - It will try to retrieve the token using the cache, or the credential's getToken. + * - Then it will try the next policy with or without the retrieved token. + * + * It uses the challenge parameters to: + * - Skip a first attempt to get the token from the credential if there's no cached token, + * since it expects the token to be retrievable only after the challenge. + * - Prepare the outgoing request if the `prepareRequest` method has been provided. + * - Send an initial request to receive the challenge if it fails. + * - Process a challenge if the response contains it. + * - Retrieve a token with the challenge information, then re-send the request. + */ + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + await callbacks.authorizeRequest({ + scopes, + request, + getAccessToken: cycler.getToken + }); + + let response: PipelineResponse; + let error: Error | undefined; + try { + response = await next(request); + } catch (err) { + error = err; + response = err.response; + } + + if ( + callbacks.authorizeRequestOnChallenge && + response?.status === 401 && + getChallenge(response) + ) { + // processes challenge + const shouldSendRequest = await callbacks.authorizeRequestOnChallenge({ + scopes, + request, + response, + getAccessToken: cycler.getToken + }); + + if (shouldSendRequest) { + return next(request); + } + } + + if (error) { + throw error; + } else { + return response; + } + } + }; +} diff --git a/sdk/core/core-rest-pipeline/src/util/tokenCycler.ts b/sdk/core/core-rest-pipeline/src/util/tokenCycler.ts index 30022631b125..4e096a667110 100644 --- a/sdk/core/core-rest-pipeline/src/util/tokenCycler.ts +++ b/sdk/core/core-rest-pipeline/src/util/tokenCycler.ts @@ -10,7 +10,18 @@ import { delay } from "./helpers"; * * @param options - the options to pass to the underlying token provider */ -type AccessTokenGetter = (options: GetTokenOptions) => Promise; +export type AccessTokenGetter = ( + scopes: string | string[], + options: GetTokenOptions +) => Promise; + +/** + * The response of the + */ +export interface AccessTokenRefresher { + cachedToken: AccessToken | null; + getToken: AccessTokenGetter; +} export interface TokenCyclerOptions { /** @@ -97,16 +108,14 @@ async function beginRefresh( * * @param credential - the underlying TokenCredential that provides the access * token - * @param scopes - the scopes to request authorization for * @param tokenCyclerOptions - optionally override default settings for the cycler * * @returns - a function that reliably produces a valid access token */ export function createTokenCycler( credential: TokenCredential, - scopes: string | string[], tokenCyclerOptions?: Partial -): AccessTokenGetter { +): AccessTokenRefresher { let refreshWorker: Promise | null = null; let token: AccessToken | null = null; @@ -151,7 +160,10 @@ export function createTokenCycler( * Starts a refresh job or returns the existing job if one is already * running. */ - function refresh(getTokenOptions: GetTokenOptions): Promise { + 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 => @@ -183,23 +195,31 @@ export function createTokenCycler( return refreshWorker as Promise; } - return async (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. - // - - if (cycler.mustRefresh) return refresh(tokenOptions); - - if (cycler.shouldRefresh) { - refresh(tokenOptions); - } + return { + get cachedToken(): AccessToken | null { + return token; + }, + getToken: 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. + // + + if (cycler.mustRefresh) return refresh(scopes, tokenOptions); + + if (cycler.shouldRefresh) { + refresh(scopes, tokenOptions); + } - return token as AccessToken; + return token as AccessToken; + } }; } diff --git a/sdk/core/core-rest-pipeline/test/bearerTokenAuthenticationPolicy.spec.ts b/sdk/core/core-rest-pipeline/test/bearerTokenAuthenticationPolicy.spec.ts index 5072a69ba91e..cdc12a215d95 100644 --- a/sdk/core/core-rest-pipeline/test/bearerTokenAuthenticationPolicy.spec.ts +++ b/sdk/core/core-rest-pipeline/test/bearerTokenAuthenticationPolicy.spec.ts @@ -4,7 +4,6 @@ import { assert } from "chai"; import * as sinon from "sinon"; import { TokenCredential, AccessToken } from "@azure/core-auth"; -import {} from "../src/policies/bearerTokenAuthenticationPolicy"; import { PipelinePolicy, createPipelineRequest, diff --git a/sdk/core/core-rest-pipeline/test/node/bearerTokenChallengeAuthenticationPolicy.spec.ts b/sdk/core/core-rest-pipeline/test/node/bearerTokenChallengeAuthenticationPolicy.spec.ts new file mode 100644 index 000000000000..933a05a627de --- /dev/null +++ b/sdk/core/core-rest-pipeline/test/node/bearerTokenChallengeAuthenticationPolicy.spec.ts @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import * as sinon from "sinon"; +import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth"; +import { + bearerTokenChallengeAuthenticationPolicy, + AuthorizeRequestOnChallengeOptions, + createEmptyPipeline, + createHttpHeaders, + createPipelineRequest, + HttpClient, + PipelineResponse +} from "../../src"; +import { TextDecoder } from "util"; + +export interface TestChallenge { + scope: string; + claims: string; +} + +let cachedChallenge: string | undefined; + +/** + * Converts a uint8Array to a string. + */ +export function uint8ArrayToString(ab: Uint8Array): string { + const decoder = new TextDecoder("utf-8"); + return decoder.decode(ab); +} + +/** + * Encodes a string in base64 format. + * @param value - The string to encode + */ +export function encodeString(value: string): string { + return Buffer.from(value).toString("base64"); +} + +/** + * Decodes a base64 string into a byte array. + * @param value - The base64 string to decode + */ +export function decodeString(value: string): Uint8Array { + return Buffer.from(value, "base64"); +} + +// Converts: +// Bearer a="b", c="d", Bearer d="e", f="g" +// Into: +// [ { a: 'b', c: 'd' }, { d: 'e', f: 'g"' } ] +// Important: +// Do not use this in production, as values might contain the strings we use to split things up. +function parseCAEChallenge(challenges: string): any[] { + return challenges + .split("Bearer ") + .filter((x) => x) + .map((challenge) => + `${challenge.trim()}, ` + .split('", ') + .filter((x) => x) + .map((keyValue) => (([key, value]) => ({ [key]: value }))(keyValue.trim().split('="'))) + .reduce((a, b) => ({ ...a, ...b }), {}) + ); +} + +async function authorizeRequestOnChallenge( + options: AuthorizeRequestOnChallengeOptions +): Promise { + const { scopes } = options; + + const challenge = options.response.headers.get("WWW-Authenticate"); + if (!challenge) { + throw new Error("Missing challenge"); + } + const challenges: TestChallenge[] = parseCAEChallenge(challenge) || []; + + const parsedChallenge = challenges.find((x) => x.claims); + if (!parsedChallenge) { + throw new Error("Missing claims"); + } + if (cachedChallenge !== challenge) { + cachedChallenge = challenge; + } + + const accessToken = await options.getAccessToken( + parsedChallenge.scope ? [parsedChallenge.scope] : scopes, + { + ...options, + claims: uint8ArrayToString(Buffer.from(parsedChallenge.claims, "base64")) + } as GetTokenOptions + ); + + if (!accessToken) { + return false; + } + + options.request.headers.set("Authorization", `Bearer ${accessToken.token}`); + return true; +} + +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()!); + } +} + +describe("bearerTokenAuthenticationPolicy with challenge", function() { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + afterEach(() => { + clock.restore(); + }); + + it("tests that the scope and the claim have been passed through to getToken correctly", 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 = bearerTokenChallengeAuthenticationPolicy({ + // 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 + } + }); + 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); + + // Our goal is to test that: + // - Only one getToken request was sent. + // - That the only getToken request contained the scope and the claims of the challenge. + // - That the HTTP requests that were sent were: + // - Once without the token, to retrieve the challenge. + // - A final one with the token. + + assert.equal(credential.authCount, 1); + assert.deepEqual(credential.scopesAndClaims, [ + { + scope: expected.scope, + challengeClaims: expected.challengeClaims + } + ]); + assert.deepEqual(finalSendRequestHeaders, [undefined, `Bearer ${getTokenResponse.token}`]); + }); + + it("tests that the challenge is processed even we already had a token", async function() { + const expected = [ + { + scope: ["http://localhost/.default"], + challengeClaims: JSON.stringify({ + access_token: { foo: "bar" } + }) + }, + { + scope: ["http://localhost/.default2"], + challengeClaims: JSON.stringify({ + access_token: { foo2: "bar2" } + }) + } + ]; + + const pipelineRequest = createPipelineRequest({ url: "https://example.com" }); + const responses: PipelineResponse[] = [ + { + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer scope="${expected[0].scope[0]}", claims="${encodeString( + expected[0].challengeClaims + )}"` + }), + request: pipelineRequest, + status: 401 + }, + { + headers: createHttpHeaders(), + request: pipelineRequest, + status: 200 + }, + { + headers: createHttpHeaders({ + "WWW-Authenticate": `Bearer scope="${expected[1].scope[0]}", claims="${encodeString( + expected[1].challengeClaims + )}"` + }), + request: pipelineRequest, + status: 401 + }, + { + headers: createHttpHeaders(), + request: pipelineRequest, + status: 200 + } + ]; + + const getTokenResponses = [ + { token: "mock-token", expiresOnTimestamp: Date.now() + 5000 }, + { token: "mock-token2", expiresOnTimestamp: Date.now() + 10000 } + ]; + const credential = new MockRefreshAzureCredential([...getTokenResponses]); + + const pipeline = createEmptyPipeline(); + let firstRequest: boolean = true; + let previousToken: AccessToken | null; + const bearerPolicy = bearerTokenChallengeAuthenticationPolicy({ + // 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 { + if (!previousToken) { + previousToken = await getAccessToken([], {}); + if (!previousToken) { + throw new Error("Failed to retrieve an access token"); + } + } + request.headers.set("Authorization", `Bearer ${previousToken.token}`); + } + }, + authorizeRequestOnChallenge + } + }); + 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); + clock.tick(5000); + await pipeline.sendRequest(testHttpsClient, pipelineRequest); + + // Our goal is to test that: + // - After a second challenge was received, we processed it and retrieved the token again. + + assert.equal(credential.authCount, 3); + assert.deepEqual(credential.scopesAndClaims, [ + { + scope: expected[0].scope, + challengeClaims: expected[0].challengeClaims + }, + { + scope: [], + challengeClaims: undefined + }, + { + scope: expected[1].scope, + challengeClaims: expected[1].challengeClaims + } + ]); + assert.deepEqual(finalSendRequestHeaders, [ + undefined, + `Bearer ${getTokenResponses[0].token}`, + `Bearer ${getTokenResponses[1].token}`, + `Bearer ${getTokenResponses[1].token}` + ]); + }); + + it("service errors without challenges should bubble up", async function() { + const pipelineRequest = createPipelineRequest({ url: "https://example.com" }); + const credential = new MockRefreshAzureCredential([]); + + const pipeline = createEmptyPipeline(); + let firstRequest: boolean = true; + const bearerPolicy = bearerTokenChallengeAuthenticationPolicy({ + // 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 + } + }); + pipeline.addPolicy(bearerPolicy); + + const testHttpsClient: HttpClient = { + sendRequest: async (req) => { + throw { + message: "Failed sendRequest error", + response: { + headers: createHttpHeaders(), + request: req, + status: 400 + } + }; + } + }; + + let error: Error | undefined; + try { + await pipeline.sendRequest(testHttpsClient, pipelineRequest); + } catch (e) { + error = e; + } + + assert.ok(error); + assert.equal(error?.message, "Failed sendRequest error"); + }); +});