From 010a78ce34b5af0379f0cfacd96771cdbd98174d Mon Sep 17 00:00:00 2001 From: Frederik Prijck Date: Wed, 10 Sep 2025 15:46:48 +0200 Subject: [PATCH] Demonstrate changes needed to Supplier and Fetcher to be Scope-Aware --- .../resources/versions/client/Client.ts | 18 +++++-- src/management/core/fetcher/Fetcher.ts | 1 + src/management/core/fetcher/Supplier.ts | 9 ++-- .../management-client-fetch-with-auth.test.ts | 51 +++++++++++++++++++ 4 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 src/management/tests/unit/management-client-fetch-with-auth.test.ts diff --git a/src/management/api/resources/actions/resources/versions/client/Client.ts b/src/management/api/resources/actions/resources/versions/client/Client.ts index 826c1e15f..4d418e3d0 100644 --- a/src/management/api/resources/actions/resources/versions/client/Client.ts +++ b/src/management/api/resources/actions/resources/versions/client/Client.ts @@ -60,6 +60,9 @@ export class Versions { request: Management.actions.ListActionVersionsRequestParameters = {}, requestOptions?: Versions.RequestOptions, ): Promise> { + // This variable is populated from the Open API specification file. + const endpointScopes = ["read:actions_versions"]; + const list = core.HttpResponsePromise.interceptFunction( async ( request: Management.actions.ListActionVersionsRequestParameters, @@ -82,7 +85,8 @@ export class Versions { method: "GET", headers: mergeHeaders( this._options?.headers, - mergeOnlyDefinedHeaders({ Authorization: await this._getAuthorizationHeader() }), + // Here we pass the scope for the endpoint to the Supplier to ensure the scope is used when calling the token supplier. + mergeOnlyDefinedHeaders({ Authorization: await this._getAuthorizationHeader(endpointScopes) }), requestOptions?.headers, ), queryParameters: { ..._queryParams, ...requestOptions?.queryParams }, @@ -90,6 +94,9 @@ export class Versions { requestOptions?.timeoutInSeconds != null ? requestOptions.timeoutInSeconds * 1000 : 60000, maxRetries: requestOptions?.maxRetries, abortSignal: requestOptions?.abortSignal, + // Here we pass the scope for the endpoint to the fetcher, this enables the fetcher to take full control over the authorization aspect. + // Doing so enables advanced use-cases like DPoP as well as token renewal when the token is expired. + scope: endpointScopes, }); if (_response.ok) { return { @@ -348,7 +355,12 @@ export class Versions { } } - protected async _getAuthorizationHeader(): Promise { - return `Bearer ${await core.Supplier.get(this._options.token)}`; + // Marked scope as optional to avoid having to update every snippet for demonstrating the changes. + protected async _getAuthorizationHeader(scope?: string[] | undefined): Promise { + const token = await core.Supplier.get(this._options.token, { scope }); + + // Ensure to not add an empty Bearer token. + // Doing this ensures the header is filtered out by `mergeOnlyDefinedHeaders`. + return token && `Bearer ${token}`; } } diff --git a/src/management/core/fetcher/Fetcher.ts b/src/management/core/fetcher/Fetcher.ts index 693dad886..8f4c2220d 100644 --- a/src/management/core/fetcher/Fetcher.ts +++ b/src/management/core/fetcher/Fetcher.ts @@ -27,6 +27,7 @@ export declare namespace Fetcher { requestType?: "json" | "file" | "bytes"; responseType?: "json" | "blob" | "sse" | "streaming" | "text" | "arrayBuffer" | "binary-response"; duplex?: "half"; + scope?: string[]; } export type Error = FailedStatusCodeError | NonJsonError | TimeoutError | UnknownError; diff --git a/src/management/core/fetcher/Supplier.ts b/src/management/core/fetcher/Supplier.ts index 867c931c0..5653785b6 100644 --- a/src/management/core/fetcher/Supplier.ts +++ b/src/management/core/fetcher/Supplier.ts @@ -1,9 +1,12 @@ -export type Supplier = T | Promise | (() => T | Promise); +export type SupplierContext = { scope: string[] | undefined }; +export type SupplierFn = (context?: SupplierContext) => T | Promise; +export type Supplier = T | Promise | SupplierFn; export const Supplier = { - get: async (supplier: Supplier): Promise => { + // Marked context as optional to not have to update all snippets. + get: async (supplier: Supplier, context?: SupplierContext): Promise => { if (typeof supplier === "function") { - return (supplier as () => T)(); + return (supplier as SupplierFn)(context); } else { return supplier; } diff --git a/src/management/tests/unit/management-client-fetch-with-auth.test.ts b/src/management/tests/unit/management-client-fetch-with-auth.test.ts new file mode 100644 index 000000000..eb11e5de9 --- /dev/null +++ b/src/management/tests/unit/management-client-fetch-with-auth.test.ts @@ -0,0 +1,51 @@ +import { ManagementClient } from "../../wrapper/ManagementClient.js"; + +describe("ManagementClient with Scope-Aware Supplier and Fetcher", () => { + it("handle custom changes to demonstrate", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(); + + const client = new ManagementClient({ + domain: "your-tenant.auth0.com", + clientId: "your-client-id", + clientSecret: "your-client-secret", + token: (context) => { + console.log(`Scope passed to token supplier: ${context?.scope?.join(", ")}`); + return "your-static-token"; + }, + fetcher: async (args) => { + console.log(`Scope passed to fetcher: ${args.scope?.join(", ")}`); + const response = await fetch(args.url, { + method: args.method, + headers: args.headers as Record, + body: args.body ? JSON.stringify(args.body) : undefined, + }); + + if (response.ok) { + return { + ok: true as const, + body: await response.json(), + headers: response.headers, + rawResponse: response, + }; + } else { + return { + ok: false as const, + error: { + reason: "status-code" as const, + statusCode: response.status, + body: await response.text(), + }, + rawResponse: response, + }; + } + }, + }); + + try { + await client.actions.versions.list("action123"); + } catch {} + + expect(consoleSpy).toHaveBeenNthCalledWith(1, "Scope passed to token supplier: read:actions_versions"); + expect(consoleSpy).toHaveBeenNthCalledWith(2, "Scope passed to fetcher: read:actions_versions"); + }); +});