diff --git a/packages/http/fetch/src/index.ts b/packages/http/fetch/src/index.ts index 3f711ec23..2f3e4a6e0 100644 --- a/packages/http/fetch/src/index.ts +++ b/packages/http/fetch/src/index.ts @@ -11,6 +11,7 @@ export * from "./fetchRequestAdapter"; export * from "./httpClient"; export * from "./middlewares/middleware"; +export * from "./middlewares/authorizationHandler"; export * from "./middlewares/chaosHandler"; export * from "./middlewares/customFetchHandler"; export * from "./middlewares/compressionHandler"; diff --git a/packages/http/fetch/src/kiotaClientFactory.ts b/packages/http/fetch/src/kiotaClientFactory.ts index a0761c183..41f56d1c0 100644 --- a/packages/http/fetch/src/kiotaClientFactory.ts +++ b/packages/http/fetch/src/kiotaClientFactory.ts @@ -7,6 +7,8 @@ import { Middleware, MiddlewareFactory } from "."; import { HttpClient } from "./httpClient"; +import { BaseBearerTokenAuthenticationProvider } from "@microsoft/kiota-abstractions"; +import { AuthorizationHandler } from "./middlewares/authorizationHandler"; /** * @@ -21,6 +23,7 @@ export class KiotaClientFactory { * If middlewares param is undefined, the httpClient instance will use the default array of middlewares. * Set middlewares to `null` if you do not wish to use middlewares. * If custom fetch is undefined, the httpClient instance uses the `DefaultFetchHandler` + * @param authenticationProvider - an optional instance of BaseBearerTokenAuthenticationProvider to be used for authentication * @returns a HttpClient instance * @example * ```Typescript @@ -44,8 +47,11 @@ export class KiotaClientFactory { * ``` */ // eslint-disable-next-line @typescript-eslint/no-unsafe-return - public static create(customFetch: (request: string, init: RequestInit) => Promise = (...args) => fetch(...args) as any, middlewares?: Middleware[]): HttpClient { + public static create(customFetch: (request: string, init: RequestInit) => Promise = (...args) => fetch(...args) as any, middlewares?: Middleware[], authenticationProvider?: BaseBearerTokenAuthenticationProvider): HttpClient { const middleware = middlewares || MiddlewareFactory.getDefaultMiddlewares(customFetch); + if (authenticationProvider) { + middleware.unshift(new AuthorizationHandler(authenticationProvider)); + } return new HttpClient(customFetch, ...middleware); } } diff --git a/packages/http/fetch/src/middlewares/authorizationHandler.ts b/packages/http/fetch/src/middlewares/authorizationHandler.ts new file mode 100644 index 000000000..2c0916155 --- /dev/null +++ b/packages/http/fetch/src/middlewares/authorizationHandler.ts @@ -0,0 +1,109 @@ +/** + * ------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License. + * See License in the project root for license information. + * ------------------------------------------------------------------------------------------- + */ + +import { type RequestOption } from "@microsoft/kiota-abstractions"; +import { Span, trace } from "@opentelemetry/api"; + +import { getObservabilityOptionsFromRequest } from "../observabilityOptions"; +import type { Middleware } from "./middleware"; +import type { FetchRequestInit } from "../utils/fetchDefinitions"; +import { getRequestHeader, setRequestHeader } from "../utils/headersUtil"; +import { BaseBearerTokenAuthenticationProvider } from "@microsoft/kiota-abstractions"; + +export class AuthorizationHandler implements Middleware { + next: Middleware | undefined; + + /** + * A member holding the name of content range header + */ + private static readonly AUTHORIZATION_HEADER = "Authorization"; + + public constructor(private readonly authenticationProvider: BaseBearerTokenAuthenticationProvider) { + if (!authenticationProvider) { + throw new Error("authenticationProvider cannot be undefined"); + } + } + + public execute(url: string, requestInit: RequestInit, requestOptions?: Record): Promise { + const obsOptions = getObservabilityOptionsFromRequest(requestOptions); + if (obsOptions) { + return trace.getTracer(obsOptions.getTracerInstrumentationName()).startActiveSpan("authorizationHandler - execute", (span) => { + try { + span.setAttribute("com.microsoft.kiota.handler.authorization.enable", true); + return this.executeInternal(url, requestInit as FetchRequestInit, requestOptions, span); + } finally { + span.end(); + } + }); + } + return this.executeInternal(url, requestInit as FetchRequestInit, requestOptions, undefined); + } + + private async executeInternal(url: string, fetchRequestInit: FetchRequestInit, requestOptions?: Record, span?: Span): Promise { + if (this.authorizationIsPresent(fetchRequestInit)) { + span?.setAttribute("com.microsoft.kiota.handler.authorization.token_present", true); + return await this.next!.execute(url, fetchRequestInit as RequestInit, requestOptions); + } + + const token = await this.authenticateRequest(url); + setRequestHeader(fetchRequestInit, AuthorizationHandler.AUTHORIZATION_HEADER, `Bearer ${token}`); + const response = await this.next?.execute(url, fetchRequestInit as RequestInit, requestOptions); + if (!response) { + throw new Error("Response is undefined"); + } + if (response.status !== 401) { + return response; + } + const claims = this.getClaimsFromResponse(response); + if (!claims) { + return response; + } + span?.addEvent("com.microsoft.kiota.handler.authorization.challenge_received"); + const claimsToken = await this.authenticateRequest(url, claims); + setRequestHeader(fetchRequestInit, AuthorizationHandler.AUTHORIZATION_HEADER, `Bearer ${claimsToken}`); + span?.setAttribute("http.request.resend_count", 1); + const retryResponse = await this.next?.execute(url, fetchRequestInit as RequestInit, requestOptions); + if (!retryResponse) { + throw new Error("Response is undefined"); + } + return retryResponse; + } + + private authorizationIsPresent(request: FetchRequestInit | undefined): boolean { + if (!request) { + return false; + } + const authorizationHeader = getRequestHeader(request, AuthorizationHandler.AUTHORIZATION_HEADER); + return authorizationHeader !== undefined && authorizationHeader !== null; + } + + private async authenticateRequest(url: string, claims?: string): Promise { + const additionalAuthenticationContext = {} as Record; + if (claims) { + additionalAuthenticationContext.claims = claims; + } + return await this.authenticationProvider.accessTokenProvider.getAuthorizationToken(url, additionalAuthenticationContext); + } + + private readonly getClaimsFromResponse = (response: Response, claims?: string) => { + if (response.status === 401 && !claims) { + // avoid infinite loop, we only retry once + // no need to check for the content since it's an array and it doesn't need to be rewound + const rawAuthenticateHeader = response.headers.get("WWW-Authenticate"); + if (rawAuthenticateHeader && /^Bearer /gi.test(rawAuthenticateHeader)) { + const rawParameters = rawAuthenticateHeader.replace(/^Bearer /gi, "").split(","); + for (const rawParameter of rawParameters) { + const trimmedParameter = rawParameter.trim(); + if (/claims="[^"]+"/gi.test(trimmedParameter)) { + return trimmedParameter.replace(/claims="([^"]+)"/gi, "$1"); + } + } + } + } + return undefined; + }; +} diff --git a/packages/http/fetch/test/common/middleware/authorizationHandler.ts b/packages/http/fetch/test/common/middleware/authorizationHandler.ts new file mode 100644 index 000000000..de6f76829 --- /dev/null +++ b/packages/http/fetch/test/common/middleware/authorizationHandler.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { AuthorizationHandler } from "../../../src"; +import { DummyFetchHandler } from "./dummyFetchHandler"; +import { AccessTokenProvider, AllowedHostsValidator, BaseBearerTokenAuthenticationProvider } from "@microsoft/kiota-abstractions"; + +describe("AuthorizationHandler", () => { + let authorizationHandler: AuthorizationHandler; + let nextMiddleware: DummyFetchHandler; + + beforeEach(() => { + nextMiddleware = new DummyFetchHandler(); + const validator = new AllowedHostsValidator(); + const tokenProvider: AccessTokenProvider = { + getAuthorizationToken: async () => "New Token", + getAllowedHostsValidator: () => validator, + }; + const provider = new BaseBearerTokenAuthenticationProvider(tokenProvider); + authorizationHandler = new AuthorizationHandler(provider); + authorizationHandler.next = nextMiddleware; + }); + + it("should not add a header if authorization header exists", async () => { + const url = "https://example.com"; + const requestInit = { + headers: { + Authorization: "Bearer Existing Token", + }, + }; + nextMiddleware.setResponses([new Response("ok", { status: 200 })]); + + await authorizationHandler.execute(url, requestInit); + + expect((requestInit.headers as Record)["Authorization"]).toBe("Bearer Existing Token"); + }); + + it("should attempt to authenticate when the header does not exist", async () => { + const url = "https://example.com"; + const requestInit = { headers: {} }; + nextMiddleware.setResponses([new Response("ok", { status: 200 })]); + + await authorizationHandler.execute(url, requestInit); + + expect((requestInit.headers as Record)["Authorization"]).toBe("Bearer New Token"); + }); +}); diff --git a/packages/http/fetch/test/node/kiotaClientFactory.ts b/packages/http/fetch/test/node/kiotaClientFactory.ts index 668e6d215..01c97206f 100644 --- a/packages/http/fetch/test/node/kiotaClientFactory.ts +++ b/packages/http/fetch/test/node/kiotaClientFactory.ts @@ -6,7 +6,8 @@ */ import { assert, describe, it } from "vitest"; -import { CustomFetchHandler, HeadersInspectionHandler, KiotaClientFactory, ParametersNameDecodingHandler, RedirectHandler, RetryHandler, UrlReplaceHandler, UserAgentHandler } from "../../src"; +import { CustomFetchHandler, HeadersInspectionHandler, KiotaClientFactory, ParametersNameDecodingHandler, RedirectHandler, RetryHandler, UrlReplaceHandler, UserAgentHandler, AuthorizationHandler } from "../../src"; +import { BaseBearerTokenAuthenticationProvider } from "@microsoft/kiota-abstractions"; describe("browser - KiotaClientFactory", () => { it("Should return the http client", () => { @@ -36,4 +37,19 @@ describe("browser - KiotaClientFactory", () => { assert.isTrue(middleware?.next?.next?.next instanceof RedirectHandler); assert.isTrue(middleware?.next?.next?.next?.next instanceof HeadersInspectionHandler); }); + + it("Should add an AuthorizationHandler if authenticationProvider is defined ", () => { + const middlewares = [new UserAgentHandler(), new ParametersNameDecodingHandler(), new RetryHandler(), new RedirectHandler(), new HeadersInspectionHandler()]; + + const authenticationProvider = new BaseBearerTokenAuthenticationProvider({} as any); + const httpClient = KiotaClientFactory.create(undefined, middlewares, authenticationProvider); + assert.isDefined(httpClient); + assert.isDefined(httpClient["middleware"]); + const middleware = httpClient["middleware"]; + assert.isTrue(middleware instanceof AuthorizationHandler); + assert.isTrue(middleware?.next instanceof UserAgentHandler); + assert.isTrue(middleware?.next?.next instanceof ParametersNameDecodingHandler); + assert.isTrue(middleware?.next?.next?.next instanceof RetryHandler); + assert.isTrue(middleware?.next?.next?.next?.next instanceof RedirectHandler); + }); });