diff --git a/sdk/core/core-rest-pipeline/CHANGELOG.md b/sdk/core/core-rest-pipeline/CHANGELOG.md index 2499fb3d8b18..a996008bf09b 100644 --- a/sdk/core/core-rest-pipeline/CHANGELOG.md +++ b/sdk/core/core-rest-pipeline/CHANGELOG.md @@ -1,9 +1,11 @@ # Release History -## 1.10.4 (Unreleased) +## 1.11.0 (Unreleased) ### Features Added +- Add a policy `auxiliaryAuthenticationHeaderPolicy` for external tokens to `x-ms-authorization-auxiliary` header. This header will be used when creating a cross-tenant application we may need to handle authentication requests for resources that are in different tenants. [PR #25270](https://github.com/Azure/azure-sdk-for-js/pull/25270) + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/core/core-rest-pipeline/package.json b/sdk/core/core-rest-pipeline/package.json index 24eb5101aead..c761e7372e19 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.10.4", + "version": "1.11.0", "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 e193b03bc959..acb62d6d7dbe 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 @@ -48,6 +48,19 @@ export interface AuthorizeRequestOptions { scopes: string[]; } +// @public +export function auxiliaryAuthenticationHeaderPolicy(options: AuxiliaryAuthenticationHeaderPolicyOptions): PipelinePolicy; + +// @public +export const auxiliaryAuthenticationHeaderPolicyName = "auxiliaryAuthenticationHeaderPolicy"; + +// @public +export interface AuxiliaryAuthenticationHeaderPolicyOptions { + credentials?: TokenCredential[]; + logger?: AzureLogger; + scopes: string | string[]; +} + // @public export function bearerTokenAuthenticationPolicy(options: BearerTokenAuthenticationPolicyOptions): PipelinePolicy; diff --git a/sdk/core/core-rest-pipeline/src/index.ts b/sdk/core/core-rest-pipeline/src/index.ts index 516e34327dab..045b9cd132d4 100644 --- a/sdk/core/core-rest-pipeline/src/index.ts +++ b/sdk/core/core-rest-pipeline/src/index.ts @@ -87,3 +87,8 @@ export { AuthorizeRequestOnChallengeOptions, } from "./policies/bearerTokenAuthenticationPolicy"; export { ndJsonPolicy, ndJsonPolicyName } from "./policies/ndJsonPolicy"; +export { + auxiliaryAuthenticationHeaderPolicy, + AuxiliaryAuthenticationHeaderPolicyOptions, + auxiliaryAuthenticationHeaderPolicyName, +} from "./policies/auxiliaryAuthenticationHeaderPolicy"; diff --git a/sdk/core/core-rest-pipeline/src/policies/auxiliaryAuthenticationHeaderPolicy.ts b/sdk/core/core-rest-pipeline/src/policies/auxiliaryAuthenticationHeaderPolicy.ts new file mode 100644 index 000000000000..6d6f8d9558aa --- /dev/null +++ b/sdk/core/core-rest-pipeline/src/policies/auxiliaryAuthenticationHeaderPolicy.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { GetTokenOptions, TokenCredential } from "@azure/core-auth"; +import { AzureLogger } from "@azure/logger"; +import { PipelineRequest, PipelineResponse, SendRequest } from "../interfaces"; +import { PipelinePolicy } from "../pipeline"; +import { AccessTokenGetter, createTokenCycler } from "../util/tokenCycler"; +import { logger as coreLogger } from "../log"; +import { AuthorizeRequestOptions } from "./bearerTokenAuthenticationPolicy"; + +/** + * The programmatic identifier of the auxiliaryAuthenticationHeaderPolicy. + */ +export const auxiliaryAuthenticationHeaderPolicyName = "auxiliaryAuthenticationHeaderPolicy"; +const AUTHORIZATION_AUXILIARY_HEADER = "x-ms-authorization-auxiliary"; + +/** + * Options to configure the auxiliaryAuthenticationHeaderPolicy + */ +export interface AuxiliaryAuthenticationHeaderPolicyOptions { + /** + * TokenCredential list used to get token from auxiliary tenants and + * one credential for each tenant the client may need to access + */ + credentials?: TokenCredential[]; + /** + * Scopes depend on the cloud your application runs in + */ + scopes: string | string[]; + /** + * A logger can be sent for debugging purposes. + */ + logger?: AzureLogger; +} + +async function sendAuthorizeRequest(options: AuthorizeRequestOptions): Promise { + const { scopes, getAccessToken, request } = options; + const getTokenOptions: GetTokenOptions = { + abortSignal: request.abortSignal, + tracingOptions: request.tracingOptions, + }; + + return (await getAccessToken(scopes, getTokenOptions))?.token ?? ""; +} + +/** + * A policy for external tokens to `x-ms-authorization-auxiliary` header. + * This header will be used when creating a cross-tenant application we may need to handle authentication requests + * for resources that are in different tenants. + * You could see [ARM docs](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/authenticate-multi-tenant) for a rundown of how this feature works + */ +export function auxiliaryAuthenticationHeaderPolicy( + options: AuxiliaryAuthenticationHeaderPolicyOptions +): PipelinePolicy { + const { credentials, scopes } = options; + const logger = options.logger || coreLogger; + const tokenCyclerMap = new WeakMap(); + + return { + name: auxiliaryAuthenticationHeaderPolicyName, + async sendRequest(request: PipelineRequest, next: SendRequest): Promise { + if (!request.url.toLowerCase().startsWith("https://")) { + throw new Error( + "Bearer token authentication for auxiliary header is not permitted for non-TLS protected (non-https) URLs." + ); + } + if (!credentials || credentials.length === 0) { + logger.info( + `${auxiliaryAuthenticationHeaderPolicyName} header will not be set due to empty credentials.` + ); + return next(request); + } + + const tokenPromises: Promise[] = []; + for (const credential of credentials) { + let getAccessToken = tokenCyclerMap.get(credential); + if (!getAccessToken) { + getAccessToken = createTokenCycler(credential); + tokenCyclerMap.set(credential, getAccessToken); + } + tokenPromises.push( + sendAuthorizeRequest({ + scopes: Array.isArray(scopes) ? scopes : [scopes], + request, + getAccessToken, + logger, + }) + ); + } + const auxiliaryTokens = (await Promise.all(tokenPromises)).filter((token) => Boolean(token)); + if (auxiliaryTokens.length === 0) { + logger.warning( + `None of the auxiliary tokens are valid. ${AUTHORIZATION_AUXILIARY_HEADER} header will not be set.` + ); + return next(request); + } + request.headers.set( + AUTHORIZATION_AUXILIARY_HEADER, + auxiliaryTokens.map((token) => `Bearer ${token}`).join(", ") + ); + + return next(request); + }, + }; +} diff --git a/sdk/core/core-rest-pipeline/test/auxiliaryAuthenticationHeaderPolicy.spec.ts b/sdk/core/core-rest-pipeline/test/auxiliaryAuthenticationHeaderPolicy.spec.ts new file mode 100644 index 000000000000..42d27de83193 --- /dev/null +++ b/sdk/core/core-rest-pipeline/test/auxiliaryAuthenticationHeaderPolicy.spec.ts @@ -0,0 +1,290 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { assert } from "chai"; +import * as sinon from "sinon"; +import { AccessToken, TokenCredential } from "@azure/core-auth"; +import { + PipelinePolicy, + PipelineResponse, + SendRequest, + auxiliaryAuthenticationHeaderPolicy, + createHttpHeaders, + createPipelineRequest, +} from "../src"; +import { DEFAULT_CYCLER_OPTIONS } from "../src/util/tokenCycler"; + +const { refreshWindowInMs: defaultRefreshWindow } = DEFAULT_CYCLER_OPTIONS; + +describe("AuxiliaryAuthenticationHeaderPolicy", function () { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(Date.now()); + }); + afterEach(() => { + clock.restore(); + }); + + it("correctly adds an Authentication header with one access token", async function () { + const mockToken = "token"; + const tokenScopes = ["scope1", "scope2"]; + const fakeGetToken = sinon.fake.returns( + Promise.resolve({ token: mockToken, expiresOn: new Date() }) + ); + const mockCredential: TokenCredential = { + getToken: fakeGetToken, + }; + + const request = createPipelineRequest({ url: "https://example.com" }); + const successResponse: PipelineResponse = { + headers: createHttpHeaders(), + request, + status: 200, + }; + const next = sinon.stub, ReturnType>(); + next.resolves(successResponse); + + const mockAuxiliaryAuthenticationHeaderPolicy = createAuxiliaryAuthenticationHeaderPolicy( + tokenScopes, + [mockCredential] + ); + await mockAuxiliaryAuthenticationHeaderPolicy.sendRequest(request, next); + + assert( + fakeGetToken.calledWith(tokenScopes, { + abortSignal: undefined, + tracingOptions: undefined, + }), + "fakeGetToken called incorrectly." + ); + assert.strictEqual(request.headers.get("x-ms-authorization-auxiliary"), `Bearer ${mockToken}`); + }); + + it("correctly adds an Authentication header with two access tokens", async function () { + const mockToken1 = "token1", + mockToken2 = "token2"; + const tokenScopes = ["scope1", "scope2"]; + const fakeGetToken1 = sinon.fake.returns( + Promise.resolve({ token: mockToken1, expiresOn: new Date() }) + ); + const fakeGetToken2 = sinon.fake.returns( + Promise.resolve({ token: mockToken2, expiresOn: new Date() }) + ); + const mockCredential1: TokenCredential = { + getToken: fakeGetToken1, + }; + const mockCredential2: TokenCredential = { + getToken: fakeGetToken2, + }; + + const request = createPipelineRequest({ url: "https://example.com" }); + const successResponse: PipelineResponse = { + headers: createHttpHeaders(), + request, + status: 200, + }; + const next = sinon.stub, ReturnType>(); + next.resolves(successResponse); + + const mockAuxiliaryAuthenticationHeaderPolicy = createAuxiliaryAuthenticationHeaderPolicy( + tokenScopes, + [mockCredential1, mockCredential2] + ); + await mockAuxiliaryAuthenticationHeaderPolicy.sendRequest(request, next); + + assert( + fakeGetToken1.calledWith(tokenScopes, { + abortSignal: undefined, + tracingOptions: undefined, + }), + "fakeGetToken called incorrectly." + ); + assert.strictEqual( + request.headers.get("x-ms-authorization-auxiliary"), + `Bearer ${mockToken1}, Bearer ${mockToken2}` + ); + }); + + it("only refreshes invalid token during the refresh window", async () => { + const expireDelayMs = defaultRefreshWindow + 5000; + let tokenExpiration = Date.now() + expireDelayMs; + const shortCredential = new MockRefreshAzureCredential(tokenExpiration); + const longCredential = new MockRefreshAzureCredential(Date.now() + expireDelayMs * 3); + + const request = createPipelineRequest({ url: "https://example.com" }); + const successResponse: PipelineResponse = { + headers: createHttpHeaders(), + request, + status: 200, + }; + const next = sinon.stub, ReturnType>(); + next.resolves(successResponse); + + const policy = createAuxiliaryAuthenticationHeaderPolicy("test-scope", [ + shortCredential, + longCredential, + ]); + + // The token is cached and remains cached for a bit. + await policy.sendRequest(request, next); + await policy.sendRequest(request, next); + assert.strictEqual(shortCredential.authCount, 1); + assert.strictEqual(longCredential.authCount, 1); + assert.strictEqual( + request.headers.get("x-ms-authorization-auxiliary"), + `Bearer mock-token, Bearer mock-token` + ); + + // The token will remain cached until tokenExpiration - testTokenRefreshBufferMs, so in (5000 - 1000) milliseconds. + + // For safe measure, we test the token is still cached a second earlier than the forced refresh expectation. + clock.tick(expireDelayMs - defaultRefreshWindow - 1000); + await policy.sendRequest(request, next); + assert.strictEqual(shortCredential.authCount, 1); + assert.strictEqual(longCredential.authCount, 1); + + // The new token will last for a few minutes again. + tokenExpiration = Date.now() + expireDelayMs; + shortCredential.expiresOnTimestamp = tokenExpiration; + + // Now we wait until it expires: + clock.tick(expireDelayMs + 1000); + await policy.sendRequest(request, next); + assert.strictEqual(shortCredential.authCount, 2); + assert.strictEqual(longCredential.authCount, 1); + }); + + it("credential errors should bubble up", async () => { + const expireDelayMs = 5000; + const startTime = Date.now(); + const tokenExpiration = startTime + expireDelayMs; + const getTokenDelay = 100; + const credential = new MockRefreshAzureCredential(tokenExpiration, getTokenDelay, clock); + + const request = createPipelineRequest({ url: "https://example.com" }); + const successResponse: PipelineResponse = { + headers: createHttpHeaders(), + request, + status: 200, + }; + const next = sinon.stub, ReturnType>(); + next.resolves(successResponse); + + const policy = createAuxiliaryAuthenticationHeaderPolicy("test-scope", [credential]); + + credential.shouldThrow = true; + + let error: Error | undefined; + try { + await policy.sendRequest(request, next); + } catch (e: any) { + error = e; + } + assert.equal(error?.message, "Failed to retrieve the token"); + + assert.strictEqual( + credential.authCount, + 1, + "The first authentication attempt should have happened" + ); + }); + + it("throws if the target URI doesn't start with 'https'", async () => { + const expireDelayMs = defaultRefreshWindow + 5000; + const tokenExpiration = Date.now() + expireDelayMs; + const credential = new MockRefreshAzureCredential(tokenExpiration); + + const request = createPipelineRequest({ url: "http://example.com" }); + const policy = createAuxiliaryAuthenticationHeaderPolicy("test-scope", [credential]); + const next = sinon.stub, ReturnType>(); + + let error: Error | undefined; + try { + await policy.sendRequest(request, next); + } catch (e: any) { + error = e; + } + + assert.equal( + error?.message, + "Bearer token authentication for auxiliary header is not permitted for non-TLS protected (non-https) URLs." + ); + }); + + it("should not add auxiliary header if all tokens are invalid", async function () { + const tokenScopes = ["scope1", "scope2"]; + const fakeGetToken1 = sinon.fake.returns( + Promise.resolve({ token: null, expiresOn: new Date() }) + ); + const fakeGetToken2 = sinon.fake.returns( + Promise.resolve({ token: null, expiresOn: new Date() }) + ); + const mockCredential1: TokenCredential = { + getToken: fakeGetToken1, + }; + const mockCredential2: TokenCredential = { + getToken: fakeGetToken2, + }; + + const request = createPipelineRequest({ url: "https://example.com" }); + const successResponse: PipelineResponse = { + headers: createHttpHeaders(), + request, + status: 200, + }; + const next = sinon.stub, ReturnType>(); + next.resolves(successResponse); + + const mockAuxiliaryAuthenticationHeaderPolicy = createAuxiliaryAuthenticationHeaderPolicy( + tokenScopes, + [mockCredential1, mockCredential2] + ); + await mockAuxiliaryAuthenticationHeaderPolicy.sendRequest(request, next); + + assert( + fakeGetToken1.calledWith(tokenScopes, { + abortSignal: undefined, + tracingOptions: undefined, + }), + "fakeGetToken called incorrectly." + ); + assert.isUndefined(request.headers.get("x-ms-authorization-auxiliary")); + }); + + function createAuxiliaryAuthenticationHeaderPolicy( + scopes: string | string[], + credentials: TokenCredential[] + ): PipelinePolicy { + return auxiliaryAuthenticationHeaderPolicy({ + scopes, + credentials, + }); + } +}); + +class MockRefreshAzureCredential implements TokenCredential { + public authCount = 0; + public shouldThrow: boolean = false; + + constructor( + public expiresOnTimestamp: number, + public getTokenDelay?: number, + public clock?: sinon.SinonFakeTimers + ) {} + + public async getToken(): Promise { + this.authCount++; + + if (this.shouldThrow) { + throw new Error("Failed to retrieve the token"); + } + + // Allowing getToken to take a while + if (this.getTokenDelay && this.clock) { + this.clock.tick(this.getTokenDelay); + } + + return { token: "mock-token", expiresOnTimestamp: this.expiresOnTimestamp }; + } +}