From f3420d81a821c4548849ab36a820d5954b62c8ce Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 22 Jan 2026 11:04:04 -0500 Subject: [PATCH 1/2] feat(sdk): add User-Agent header to HTTP requests Sets User-Agent and sfdc_user_agent headers on all API requests. SDK uses 'b2c-tooling-sdk/x.x.x', CLI uses 'b2c-cli/x.x.x'. --- .changeset/user-agent-header.md | 5 + .../b2c-tooling-sdk/src/cli/base-command.ts | 5 + packages/b2c-tooling-sdk/src/clients/index.ts | 12 +- .../b2c-tooling-sdk/src/clients/middleware.ts | 35 +++++ .../b2c-tooling-sdk/src/clients/user-agent.ts | 73 +++++++++++ packages/b2c-tooling-sdk/src/index.ts | 3 + packages/b2c-tooling-sdk/src/version.ts | 31 +++++ .../test/clients/user-agent.test.ts | 123 ++++++++++++++++++ packages/b2c-tooling-sdk/test/version.test.ts | 30 +++++ 9 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 .changeset/user-agent-header.md create mode 100644 packages/b2c-tooling-sdk/src/clients/user-agent.ts create mode 100644 packages/b2c-tooling-sdk/src/version.ts create mode 100644 packages/b2c-tooling-sdk/test/clients/user-agent.test.ts create mode 100644 packages/b2c-tooling-sdk/test/version.test.ts diff --git a/.changeset/user-agent-header.md b/.changeset/user-agent-header.md new file mode 100644 index 00000000..150df100 --- /dev/null +++ b/.changeset/user-agent-header.md @@ -0,0 +1,5 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +--- + +Add User-Agent header to all HTTP requests. Sets both `User-Agent` and `sfdc_user_agent` headers with the SDK or CLI version (e.g., `b2c-cli/0.1.0` or `b2c-tooling-sdk/0.1.0`). diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index 06986eba..beee4564 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -18,6 +18,7 @@ import {configureLogger, getLogger, type LogLevel, type Logger} from '../logging import {createExtraParamsMiddleware, type ExtraParamsConfig} from '../clients/middleware.js'; import type {ConfigSource} from '../config/types.js'; import {globalMiddlewareRegistry} from '../clients/middleware-registry.js'; +import {setUserAgent} from '../clients/user-agent.js'; export type Flags = Interfaces.InferredFlags<(typeof BaseCommand)['baseFlags'] & T['flags']>; export type Args = Interfaces.InferredArgs; @@ -123,6 +124,10 @@ export abstract class BaseCommand extends Command { this.configureLogging(); + // Set CLI User-Agent (CLI name/version only, without @salesforce/ prefix) + // This must happen before any API clients are created + setUserAgent(`${this.config.name.replace(/^@salesforce\//, '')}/${this.config.version}`); + // Register extra params middleware (from --extra-query, --extra-body, --extra-headers flags) // This must happen before any API clients are created this.registerExtraParamsMiddleware(); diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 9abca497..ba490f52 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -118,8 +118,16 @@ export {WebDavClient} from './webdav.js'; export type {PropfindEntry, WebDavClientOptions} from './webdav.js'; -export {createAuthMiddleware, createLoggingMiddleware, createExtraParamsMiddleware} from './middleware.js'; -export type {ExtraParamsConfig, LoggingMiddlewareConfig} from './middleware.js'; +export { + createAuthMiddleware, + createLoggingMiddleware, + createExtraParamsMiddleware, + createUserAgentMiddleware, +} from './middleware.js'; +export type {ExtraParamsConfig, LoggingMiddlewareConfig, UserAgentConfig} from './middleware.js'; + +// User-Agent provider (auto-registers on import) +export {setUserAgent, getUserAgent, resetUserAgent, userAgentProvider} from './user-agent.js'; export {MiddlewareRegistry, globalMiddlewareRegistry} from './middleware-registry.js'; export type {HttpClientType, HttpMiddlewareProvider, UnifiedMiddleware} from './middleware-registry.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware.ts b/packages/b2c-tooling-sdk/src/clients/middleware.ts index df3a818e..b8dadddb 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware.ts @@ -205,6 +205,41 @@ export function createLoggingMiddleware(config?: string | LoggingMiddlewareConfi * })); * ``` */ +/** + * Configuration for User-Agent middleware. + */ +export interface UserAgentConfig { + /** + * The User-Agent string to set on requests. + */ + userAgent: string; +} + +/** + * Creates middleware that sets the User-Agent header on requests. + * + * Sets both the standard `User-Agent` header and a custom `sfdc_user_agent` header + * with the same value. + * + * @param config - Configuration with the User-Agent string + * @returns Middleware that sets the User-Agent headers + * + * @example + * ```typescript + * const client = createOcapiClient(config, auth); + * client.use(createUserAgentMiddleware({ userAgent: 'b2c-cli/0.1.0' })); + * ``` + */ +export function createUserAgentMiddleware(config: UserAgentConfig): Middleware { + return { + async onRequest({request}) { + request.headers.set('User-Agent', config.userAgent); + request.headers.set('sfdc_user_agent', config.userAgent); + return request; + }, + }; +} + export function createExtraParamsMiddleware(config: ExtraParamsConfig): Middleware { const logger = getLogger(); diff --git a/packages/b2c-tooling-sdk/src/clients/user-agent.ts b/packages/b2c-tooling-sdk/src/clients/user-agent.ts new file mode 100644 index 00000000..cfa0a21f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/user-agent.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * User-Agent middleware provider for HTTP clients. + * + * This module provides a global User-Agent that is applied to all HTTP requests. + * The SDK sets a default User-Agent, which can be overridden by the CLI to include + * both CLI and SDK version information. + * + * @module clients/user-agent + */ +import {SDK_USER_AGENT} from '../version.js'; +import {createUserAgentMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry} from './middleware-registry.js'; +import type {HttpMiddlewareProvider} from './middleware-registry.js'; + +// Current User-Agent string - defaults to SDK User-Agent +let currentUserAgent = SDK_USER_AGENT; + +/** + * Sets the User-Agent string for all HTTP requests. + * + * Call this early in your application to override the default SDK User-Agent. + * The CLI uses this to set a combined CLI+SDK User-Agent. + * + * @param userAgent - The User-Agent string to use + * + * @example + * ```typescript + * // CLI usage: + * setUserAgent('b2c-cli/1.0.0'); + * ``` + */ +export function setUserAgent(userAgent: string): void { + currentUserAgent = userAgent; +} + +/** + * Gets the current User-Agent string. + * + * @returns The current User-Agent string + */ +export function getUserAgent(): string { + return currentUserAgent; +} + +/** + * Resets the User-Agent to the default SDK value. + * + * Primarily useful for testing. + */ +export function resetUserAgent(): void { + currentUserAgent = SDK_USER_AGENT; +} + +/** + * User-Agent middleware provider. + * + * This provider is automatically registered with the global middleware registry + * when this module is imported. + */ +export const userAgentProvider: HttpMiddlewareProvider = { + name: 'user-agent', + getMiddleware() { + return createUserAgentMiddleware({userAgent: currentUserAgent}); + }, +}; + +// Auto-register with global middleware registry on module import +globalMiddlewareRegistry.register(userAgentProvider); diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 4db29443..35e1995b 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -210,3 +210,6 @@ export type { // Defaults export {DEFAULT_ACCOUNT_MANAGER_HOST, DEFAULT_ODS_HOST} from './defaults.js'; + +// Version info +export {SDK_NAME, SDK_VERSION, SDK_USER_AGENT} from './version.js'; diff --git a/packages/b2c-tooling-sdk/src/version.ts b/packages/b2c-tooling-sdk/src/version.ts new file mode 100644 index 00000000..e80ab944 --- /dev/null +++ b/packages/b2c-tooling-sdk/src/version.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * SDK version information and User-Agent string. + * + * @module version + */ +import {createRequire} from 'node:module'; + +const require = createRequire(import.meta.url); +const pkg = require('../package.json') as {name: string; version: string}; + +/** + * The SDK package name. + */ +export const SDK_NAME = pkg.name; + +/** + * The SDK package version. + */ +export const SDK_VERSION = pkg.version; + +/** + * Default User-Agent string for the SDK. + * + * Format: `b2c-tooling-sdk/0.1.0` + */ +export const SDK_USER_AGENT = `${SDK_NAME.replace(/^@salesforce\//, '')}/${SDK_VERSION}`; diff --git a/packages/b2c-tooling-sdk/test/clients/user-agent.test.ts b/packages/b2c-tooling-sdk/test/clients/user-agent.test.ts new file mode 100644 index 00000000..4ab9c0be --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/user-agent.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + createUserAgentMiddleware, + setUserAgent, + getUserAgent, + resetUserAgent, + userAgentProvider, +} from '@salesforce/b2c-tooling-sdk/clients'; +import {SDK_USER_AGENT} from '@salesforce/b2c-tooling-sdk'; + +describe('clients/user-agent', () => { + afterEach(() => { + // Reset to default after each test + resetUserAgent(); + }); + + describe('createUserAgentMiddleware', () => { + it('sets User-Agent header on request', async () => { + const middleware = createUserAgentMiddleware({userAgent: 'test-agent/1.0.0'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/ping', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + expect(modifiedRequest.headers.get('User-Agent')).to.equal('test-agent/1.0.0'); + }); + + it('sets sfdc_user_agent header with same value', async () => { + const middleware = createUserAgentMiddleware({userAgent: 'test-agent/1.0.0'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/ping', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + expect(modifiedRequest.headers.get('sfdc_user_agent')).to.equal('test-agent/1.0.0'); + }); + + it('overwrites existing User-Agent header', async () => { + const middleware = createUserAgentMiddleware({userAgent: 'new-agent/2.0.0'}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/ping', { + method: 'GET', + headers: {'User-Agent': 'old-agent/1.0.0'}, + }); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + expect(modifiedRequest.headers.get('User-Agent')).to.equal('new-agent/2.0.0'); + }); + }); + + describe('setUserAgent/getUserAgent', () => { + it('defaults to SDK_USER_AGENT', () => { + expect(getUserAgent()).to.equal(SDK_USER_AGENT); + }); + + it('setUserAgent changes the current User-Agent', () => { + setUserAgent('b2c-cli/1.0.0'); + expect(getUserAgent()).to.equal('b2c-cli/1.0.0'); + }); + }); + + describe('resetUserAgent', () => { + it('resets to SDK_USER_AGENT', () => { + setUserAgent('custom-agent/1.0.0'); + expect(getUserAgent()).to.equal('custom-agent/1.0.0'); + + resetUserAgent(); + expect(getUserAgent()).to.equal(SDK_USER_AGENT); + }); + }); + + describe('userAgentProvider', () => { + it('has name "user-agent"', () => { + expect(userAgentProvider.name).to.equal('user-agent'); + }); + + it('returns middleware with current User-Agent', async () => { + setUserAgent('provider-test/1.0.0'); + + const middleware = userAgentProvider.getMiddleware('ocapi'); + if (!middleware || !middleware.onRequest) { + throw new Error('Expected provider to return middleware with onRequest'); + } + + type OnRequestParams = Parameters[0]; + const request = new Request('https://example.com/ping', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + expect(modifiedRequest.headers.get('User-Agent')).to.equal('provider-test/1.0.0'); + }); + + it('returns middleware for different client types', () => { + const clientTypes = ['ocapi', 'slas', 'ods', 'mrt', 'webdav'] as const; + for (const clientType of clientTypes) { + const middleware = userAgentProvider.getMiddleware(clientType); + expect(middleware).to.not.equal(undefined, `Expected middleware for ${clientType}`); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/version.test.ts b/packages/b2c-tooling-sdk/test/version.test.ts new file mode 100644 index 00000000..8fa08e3d --- /dev/null +++ b/packages/b2c-tooling-sdk/test/version.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {createRequire} from 'node:module'; +import {SDK_NAME, SDK_VERSION, SDK_USER_AGENT} from '@salesforce/b2c-tooling-sdk'; + +const require = createRequire(import.meta.url); +const pkg = require('@salesforce/b2c-tooling-sdk/package.json') as {name: string; version: string}; + +describe('version', () => { + it('SDK_NAME matches package.json name', () => { + expect(SDK_NAME).to.equal(pkg.name); + }); + + it('SDK_VERSION matches package.json version', () => { + expect(SDK_VERSION).to.equal(pkg.version); + }); + + it('SDK_USER_AGENT has correct format (without @salesforce/ prefix)', () => { + expect(SDK_USER_AGENT).to.equal(`${pkg.name.replace(/^@salesforce\//, '')}/${pkg.version}`); + }); + + it('SDK_USER_AGENT is b2c-tooling-sdk/x.x.x format', () => { + expect(SDK_USER_AGENT).to.match(/^b2c-tooling-sdk\/\d+\.\d+\.\d+$/); + }); +}); From f6f4cb6f2450380f8c9646d6a2976c517dd5850f Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Thu, 22 Jan 2026 11:27:35 -0500 Subject: [PATCH 2/2] feat(sdk): add auth middleware for User-Agent on OAuth requests Extends the User-Agent header support to OAuth token requests by creating a separate auth middleware registry. OAuth requests previously bypassed the HTTP middleware chain since they use direct fetch() calls. - Add AuthMiddlewareRegistry for auth-specific middleware - Add b2c:auth-middleware CLI plugin hook - Apply middleware in OAuthStrategy.clientCredentialsGrant() - Register userAgentAuthProvider with the auth middleware registry --- packages/b2c-tooling-sdk/src/auth/index.ts | 9 + .../b2c-tooling-sdk/src/auth/middleware.ts | 266 +++++++++++++ packages/b2c-tooling-sdk/src/auth/oauth.ts | 35 +- .../b2c-tooling-sdk/src/cli/base-command.ts | 39 ++ packages/b2c-tooling-sdk/src/cli/hooks.ts | 92 +++++ .../b2c-tooling-sdk/src/clients/user-agent.ts | 26 +- .../test/auth/middleware.test.ts | 356 ++++++++++++++++++ 7 files changed, 812 insertions(+), 11 deletions(-) create mode 100644 packages/b2c-tooling-sdk/src/auth/middleware.ts create mode 100644 packages/b2c-tooling-sdk/test/auth/middleware.test.ts diff --git a/packages/b2c-tooling-sdk/src/auth/index.ts b/packages/b2c-tooling-sdk/src/auth/index.ts index 74ef6e4a..482752cf 100644 --- a/packages/b2c-tooling-sdk/src/auth/index.ts +++ b/packages/b2c-tooling-sdk/src/auth/index.ts @@ -84,3 +84,12 @@ export {ApiKeyStrategy} from './api-key.js'; // Resolution helpers export {resolveAuthStrategy, checkAvailableAuthMethods} from './resolve.js'; export type {ResolveAuthStrategyOptions, AvailableAuthMethods} from './resolve.js'; + +// Auth middleware +export { + globalAuthMiddlewareRegistry, + AuthMiddlewareRegistry, + applyAuthRequestMiddleware, + applyAuthResponseMiddleware, +} from './middleware.js'; +export type {AuthMiddleware, AuthMiddlewareProvider} from './middleware.js'; diff --git a/packages/b2c-tooling-sdk/src/auth/middleware.ts b/packages/b2c-tooling-sdk/src/auth/middleware.ts new file mode 100644 index 00000000..8d2e714f --- /dev/null +++ b/packages/b2c-tooling-sdk/src/auth/middleware.ts @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/** + * Auth Middleware Registry for B2C SDK. + * + * Provides a middleware system specifically for authentication requests (OAuth token requests). + * This is separate from the HTTP middleware registry because auth requests bypass the + * standard openapi-fetch client middleware chain. + * + * ## SDK Usage + * + * ```typescript + * import { globalAuthMiddlewareRegistry, AuthMiddlewareProvider } from '@salesforce/b2c-tooling-sdk/auth'; + * + * const loggingProvider: AuthMiddlewareProvider = { + * name: 'auth-logger', + * getMiddleware() { + * return { + * onRequest({ request }) { + * console.log(`[Auth] ${request.method} ${request.url}`); + * return request; + * }, + * onResponse({ response }) { + * console.log(`[Auth] ${response.status}`); + * return response; + * }, + * }; + * }, + * }; + * + * globalAuthMiddlewareRegistry.register(loggingProvider); + * ``` + * + * ## CLI Plugin Usage + * + * Plugins can provide auth middleware via the `b2c:auth-middleware` hook. + * + * @module auth/middleware + */ + +/** + * Middleware interface for authentication requests. + * + * Similar to openapi-fetch's Middleware interface, but simplified for auth requests. + */ +export interface AuthMiddleware { + /** + * Called before the auth request is sent. + * Can modify the request or return a new one. + * + * @param params - Object containing the request + * @returns Modified request, or void to use original + */ + onRequest?(params: {request: Request}): Promise; + + /** + * Called after the auth response is received. + * Can modify the response or return a new one. + * + * @param params - Object containing request and response + * @returns Modified response, or void to use original + */ + onResponse?(params: {request: Request; response: Response}): Promise; +} + +/** + * Middleware provider that supplies middleware for auth requests. + * + * @example + * ```typescript + * const provider: AuthMiddlewareProvider = { + * name: 'user-agent', + * getMiddleware() { + * return { + * onRequest({ request }) { + * request.headers.set('User-Agent', 'my-app/1.0'); + * return request; + * }, + * }; + * }, + * }; + * ``` + */ +export interface AuthMiddlewareProvider { + /** + * Human-readable name for the provider (used in logging/debugging). + */ + readonly name: string; + + /** + * Returns middleware for auth requests. + * + * @returns Middleware to apply, or undefined to skip + */ + getMiddleware(): AuthMiddleware | undefined; +} + +/** + * Registry for auth middleware providers. + * + * The registry collects middleware from multiple providers and returns + * them in registration order when requested during OAuth token requests. + * + * ## Usage Modes + * + * **SDK Mode**: Register providers directly via `register()`: + * ```typescript + * globalAuthMiddlewareRegistry.register(myProvider); + * ``` + * + * **CLI Mode**: Providers are collected via the `b2c:auth-middleware` hook + * and registered during command initialization. + */ +export class AuthMiddlewareRegistry { + private providers: AuthMiddlewareProvider[] = []; + + /** + * Registers a middleware provider. + * + * Providers are called in registration order when middleware is requested. + * + * @param provider - The provider to register + */ + register(provider: AuthMiddlewareProvider): void { + this.providers.push(provider); + } + + /** + * Unregisters a middleware provider by name. + * + * @param name - The name of the provider to remove + * @returns true if a provider was removed, false if not found + */ + unregister(name: string): boolean { + const index = this.providers.findIndex((p) => p.name === name); + if (index >= 0) { + this.providers.splice(index, 1); + return true; + } + return false; + } + + /** + * Collects middleware from all providers. + * + * @returns Array of middleware in registration order + */ + getMiddleware(): AuthMiddleware[] { + const middleware: AuthMiddleware[] = []; + + for (const provider of this.providers) { + const m = provider.getMiddleware(); + if (m) { + middleware.push(m); + } + } + + return middleware; + } + + /** + * Clears all registered providers. + * + * Primarily useful for testing. + */ + clear(): void { + this.providers = []; + } + + /** + * Returns the number of registered providers. + */ + get size(): number { + return this.providers.length; + } + + /** + * Returns the names of all registered providers. + */ + getProviderNames(): string[] { + return this.providers.map((p) => p.name); + } +} + +/** + * Global auth middleware registry instance. + * + * This is the default registry used by OAuth strategies. Register + * middleware providers here to have them applied to token requests. + * + * @example + * ```typescript + * import { globalAuthMiddlewareRegistry } from '@salesforce/b2c-tooling-sdk/auth'; + * + * globalAuthMiddlewareRegistry.register({ + * name: 'user-agent', + * getMiddleware() { + * return { + * onRequest({ request }) { + * request.headers.set('User-Agent', 'my-app/1.0'); + * return request; + * }, + * }; + * }, + * }); + * ``` + */ +export const globalAuthMiddlewareRegistry = new AuthMiddlewareRegistry(); + +/** + * Applies auth middleware to a request. + * + * This helper applies all registered `onRequest` middleware in order, + * accumulating modifications to the request. + * + * @param request - The original request + * @param middleware - Array of middleware to apply + * @returns The modified request + */ +export async function applyAuthRequestMiddleware(request: Request, middleware: AuthMiddleware[]): Promise { + let currentRequest = request; + + for (const m of middleware) { + if (m.onRequest) { + const result = await m.onRequest({request: currentRequest}); + if (result) { + currentRequest = result; + } + } + } + + return currentRequest; +} + +/** + * Applies auth middleware to a response. + * + * This helper applies all registered `onResponse` middleware in order, + * accumulating modifications to the response. + * + * @param request - The original request (for context) + * @param response - The response to process + * @param middleware - Array of middleware to apply + * @returns The modified response + */ +export async function applyAuthResponseMiddleware( + request: Request, + response: Response, + middleware: AuthMiddleware[], +): Promise { + let currentResponse = response; + + for (const m of middleware) { + if (m.onResponse) { + const result = await m.onResponse({request, response: currentResponse}); + if (result) { + currentResponse = result; + } + } + } + + return currentResponse; +} diff --git a/packages/b2c-tooling-sdk/src/auth/oauth.ts b/packages/b2c-tooling-sdk/src/auth/oauth.ts index 30c75367..d8dbabde 100644 --- a/packages/b2c-tooling-sdk/src/auth/oauth.ts +++ b/packages/b2c-tooling-sdk/src/auth/oauth.ts @@ -6,6 +6,7 @@ import type {AuthStrategy, AccessTokenResponse, DecodedJWT} from './types.js'; import {getLogger} from '../logging/logger.js'; import {DEFAULT_ACCOUNT_MANAGER_HOST} from '../defaults.js'; +import {globalAuthMiddlewareRegistry, applyAuthRequestMiddleware, applyAuthResponseMiddleware} from './middleware.js'; // Module-level token cache to support multiple instances with same clientId const ACCESS_TOKEN_CACHE: Map = new Map(); @@ -165,10 +166,26 @@ export class OAuthStrategy implements AuthStrategy { } const credentials = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64'); - const requestHeaders = { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }; + + // Build request object for middleware + let request = new Request(url, { + method, + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + // Apply auth middleware (e.g., User-Agent) + const middleware = globalAuthMiddlewareRegistry.getMiddleware(); + request = await applyAuthRequestMiddleware(request, middleware); + + // Convert headers to object for logging + const requestHeaders: Record = {}; + request.headers.forEach((value, key) => { + requestHeaders[key] = value; + }); logger.debug( {clientId: this.config.clientId}, @@ -181,11 +198,11 @@ export class OAuthStrategy implements AuthStrategy { logger.trace({method, url, headers: requestHeaders, body: params.toString()}, `[Auth REQ BODY] ${method} ${url}`); const startTime = Date.now(); - const response = await fetch(url, { - method, - headers: requestHeaders, - body: params.toString(), - }); + let response = await fetch(request); + + // Apply response middleware + response = await applyAuthResponseMiddleware(request, response, middleware); + const duration = Date.now() - startTime; // Debug: Log response summary diff --git a/packages/b2c-tooling-sdk/src/cli/base-command.ts b/packages/b2c-tooling-sdk/src/cli/base-command.ts index beee4564..8e21deed 100644 --- a/packages/b2c-tooling-sdk/src/cli/base-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/base-command.ts @@ -12,12 +12,15 @@ import type { ConfigSourcesHookResult, HttpMiddlewareHookOptions, HttpMiddlewareHookResult, + AuthMiddlewareHookOptions, + AuthMiddlewareHookResult, } from './hooks.js'; import {setLanguage} from '../i18n/index.js'; import {configureLogger, getLogger, type LogLevel, type Logger} from '../logging/index.js'; import {createExtraParamsMiddleware, type ExtraParamsConfig} from '../clients/middleware.js'; import type {ConfigSource} from '../config/types.js'; import {globalMiddlewareRegistry} from '../clients/middleware-registry.js'; +import {globalAuthMiddlewareRegistry} from '../auth/middleware.js'; import {setUserAgent} from '../clients/user-agent.js'; export type Flags = Interfaces.InferredFlags<(typeof BaseCommand)['baseFlags'] & T['flags']>; @@ -135,6 +138,9 @@ export abstract class BaseCommand extends Command { // Collect middleware from plugins before any API clients are created await this.collectPluginHttpMiddleware(); + // Collect auth middleware from plugins before any authentication is performed + await this.collectPluginAuthMiddleware(); + // Collect config sources from plugins before loading configuration await this.collectPluginConfigSources(); @@ -316,6 +322,39 @@ export abstract class BaseCommand extends Command { } } + /** + * Collects auth middleware from plugins via the `b2c:auth-middleware` hook. + * + * This method is called during command initialization, after flags are parsed + * but before any authentication is performed. It allows CLI plugins to provide + * custom middleware that will be applied to OAuth token requests. + * + * Plugin middleware is registered with the global auth middleware registry. + */ + protected async collectPluginAuthMiddleware(): Promise { + const hookOptions: AuthMiddlewareHookOptions = { + flags: this.flags as Record, + }; + + const hookResult = await this.config.runHook('b2c:auth-middleware', hookOptions); + + // Register middleware from all plugins that responded + for (const success of hookResult.successes) { + const result = success.result as AuthMiddlewareHookResult | undefined; + if (!result?.providers?.length) continue; + + for (const provider of result.providers) { + globalAuthMiddlewareRegistry.register(provider); + this.logger?.debug(`Registered auth middleware provider: ${provider.name}`); + } + } + + // Log warnings for hook failures (don't break the CLI) + for (const failure of hookResult.failures) { + this.logger?.warn(`Plugin ${failure.plugin.name} b2c:auth-middleware hook failed: ${failure.error.message}`); + } + } + /** * Handle errors thrown during command execution. * diff --git a/packages/b2c-tooling-sdk/src/cli/hooks.ts b/packages/b2c-tooling-sdk/src/cli/hooks.ts index 514b752b..21010a56 100644 --- a/packages/b2c-tooling-sdk/src/cli/hooks.ts +++ b/packages/b2c-tooling-sdk/src/cli/hooks.ts @@ -35,6 +35,7 @@ import type {Hook} from '@oclif/core'; import type {ConfigSource, ResolveConfigOptions} from '../config/types.js'; import type {HttpMiddlewareProvider} from '../clients/middleware-registry.js'; +import type {AuthMiddlewareProvider} from '../auth/middleware.js'; /** * Options passed to the `b2c:config-sources` hook. @@ -229,6 +230,93 @@ export interface HttpMiddlewareHookResult { */ export type HttpMiddlewareHook = Hook<'b2c:http-middleware'>; +// ============================================================================ +// Auth Middleware Hook +// ============================================================================ + +/** + * Options passed to the `b2c:auth-middleware` hook. + */ +export interface AuthMiddlewareHookOptions { + /** + * All parsed CLI flags from the current command. + * + * Plugins can inspect flags but cannot add new flags to commands. + * For plugin-specific configuration, use environment variables instead. + */ + flags?: Record; + /** Index signature for oclif hook compatibility */ + [key: string]: unknown; +} + +/** + * Result returned by the `b2c:auth-middleware` hook. + * + * Plugins return one or more AuthMiddlewareProvider instances that will be + * registered with the global auth middleware registry. + */ +export interface AuthMiddlewareHookResult { + /** Middleware providers to register */ + providers: AuthMiddlewareProvider[]; +} + +/** + * Hook type for `b2c:auth-middleware`. + * + * Implement this hook in your oclif plugin to provide custom middleware + * that will be applied to OAuth token requests. + * + * The hook is called during command initialization, after flags are parsed + * but before any authentication is performed. + * + * ## Plugin Registration + * + * Register the hook in your plugin's package.json: + * + * ```json + * { + * "oclif": { + * "hooks": { + * "b2c:auth-middleware": "./dist/hooks/auth-middleware.js" + * } + * } + * } + * ``` + * + * ## Hook Context + * + * Inside the hook function, you have access to: + * - `this.config` - oclif Config object + * - `this.debug()`, `this.log()`, `this.warn()`, `this.error()` - logging methods + * + * @example + * ```typescript + * import type { AuthMiddlewareHook } from '@salesforce/b2c-tooling-sdk/cli'; + * import type { AuthMiddlewareProvider } from '@salesforce/b2c-tooling-sdk/auth'; + * + * const hook: AuthMiddlewareHook = async function(options) { + * this.debug('Registering auth middleware'); + * + * const userAgentProvider: AuthMiddlewareProvider = { + * name: 'custom-user-agent', + * getMiddleware() { + * return { + * onRequest({ request }) { + * request.headers.set('User-Agent', 'my-app/1.0'); + * return request; + * }, + * }; + * }, + * }; + * + * return { providers: [userAgentProvider] }; + * }; + * + * export default hook; + * ``` + */ +export type AuthMiddlewareHook = Hook<'b2c:auth-middleware'>; + // Re-export B2C lifecycle types for convenience export type { B2COperationType, @@ -265,6 +353,10 @@ declare module '@oclif/core' { options: HttpMiddlewareHookOptions; return: HttpMiddlewareHookResult; }; + 'b2c:auth-middleware': { + options: AuthMiddlewareHookOptions; + return: AuthMiddlewareHookResult; + }; 'b2c:operation-lifecycle': { options: import('./lifecycle.js').B2COperationLifecycleHookOptions; return: import('./lifecycle.js').B2COperationLifecycleHookResult; diff --git a/packages/b2c-tooling-sdk/src/clients/user-agent.ts b/packages/b2c-tooling-sdk/src/clients/user-agent.ts index cfa0a21f..d4e96aad 100644 --- a/packages/b2c-tooling-sdk/src/clients/user-agent.ts +++ b/packages/b2c-tooling-sdk/src/clients/user-agent.ts @@ -16,6 +16,8 @@ import {SDK_USER_AGENT} from '../version.js'; import {createUserAgentMiddleware} from './middleware.js'; import {globalMiddlewareRegistry} from './middleware-registry.js'; import type {HttpMiddlewareProvider} from './middleware-registry.js'; +import {globalAuthMiddlewareRegistry} from '../auth/middleware.js'; +import type {AuthMiddlewareProvider} from '../auth/middleware.js'; // Current User-Agent string - defaults to SDK User-Agent let currentUserAgent = SDK_USER_AGENT; @@ -57,7 +59,7 @@ export function resetUserAgent(): void { } /** - * User-Agent middleware provider. + * User-Agent middleware provider for HTTP clients. * * This provider is automatically registered with the global middleware registry * when this module is imported. @@ -69,5 +71,25 @@ export const userAgentProvider: HttpMiddlewareProvider = { }, }; -// Auto-register with global middleware registry on module import +/** + * User-Agent middleware provider for auth requests. + * + * This provider is automatically registered with the global auth middleware registry + * when this module is imported. It ensures OAuth token requests include User-Agent headers. + */ +export const userAgentAuthProvider: AuthMiddlewareProvider = { + name: 'user-agent', + getMiddleware() { + return { + async onRequest({request}) { + request.headers.set('User-Agent', currentUserAgent); + request.headers.set('sfdc_user_agent', currentUserAgent); + return request; + }, + }; + }, +}; + +// Auto-register with global middleware registries on module import globalMiddlewareRegistry.register(userAgentProvider); +globalAuthMiddlewareRegistry.register(userAgentAuthProvider); diff --git a/packages/b2c-tooling-sdk/test/auth/middleware.test.ts b/packages/b2c-tooling-sdk/test/auth/middleware.test.ts new file mode 100644 index 00000000..ba0b74ad --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/middleware.test.ts @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import { + AuthMiddlewareRegistry, + applyAuthRequestMiddleware, + applyAuthResponseMiddleware, + globalAuthMiddlewareRegistry, +} from '@salesforce/b2c-tooling-sdk/auth'; +import type {AuthMiddleware, AuthMiddlewareProvider} from '@salesforce/b2c-tooling-sdk/auth'; +import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +const AM_HOST = 'account.demandware.com'; +const AM_URL = `https://${AM_HOST}/dwsso/oauth2/access_token`; + +describe('auth/middleware', () => { + describe('AuthMiddlewareRegistry', () => { + it('register() adds providers and getProviderNames() returns them', () => { + const registry = new AuthMiddlewareRegistry(); + + registry.register({ + name: 'p1', + getMiddleware() { + return undefined; + }, + }); + + registry.register({ + name: 'p2', + getMiddleware() { + return undefined; + }, + }); + + expect(registry.size).to.equal(2); + expect(registry.getProviderNames()).to.deep.equal(['p1', 'p2']); + }); + + it('unregister() removes an existing provider by name', () => { + const registry = new AuthMiddlewareRegistry(); + + registry.register({ + name: 'p1', + getMiddleware() { + return undefined; + }, + }); + + expect(registry.size).to.equal(1); + expect(registry.unregister('p1')).to.equal(true); + expect(registry.size).to.equal(0); + }); + + it('unregister() returns false when provider does not exist', () => { + const registry = new AuthMiddlewareRegistry(); + registry.register({ + name: 'p1', + getMiddleware() { + return undefined; + }, + }); + + expect(registry.unregister('missing')).to.equal(false); + expect(registry.size).to.equal(1); + }); + + it('getMiddleware() returns middleware in registration order and skips undefined', () => { + const registry = new AuthMiddlewareRegistry(); + + const m1: AuthMiddleware = { + async onRequest({request}) { + request.headers.set('x-m1', '1'); + return request; + }, + }; + + const m2: AuthMiddleware = { + async onRequest({request}) { + request.headers.set('x-m2', '2'); + return request; + }, + }; + + registry.register({ + name: 'skip', + getMiddleware() { + return undefined; + }, + }); + + registry.register({ + name: 'p1', + getMiddleware() { + return m1; + }, + }); + + registry.register({ + name: 'p2', + getMiddleware() { + return m2; + }, + }); + + const middlewares = registry.getMiddleware(); + expect(middlewares).to.have.length(2); + expect(middlewares[0]).to.equal(m1); + expect(middlewares[1]).to.equal(m2); + }); + + it('clear() removes all providers', () => { + const registry = new AuthMiddlewareRegistry(); + registry.register({ + name: 'p1', + getMiddleware() { + return undefined; + }, + }); + + expect(registry.size).to.equal(1); + registry.clear(); + expect(registry.size).to.equal(0); + expect(registry.getProviderNames()).to.deep.equal([]); + }); + }); + + describe('applyAuthRequestMiddleware', () => { + it('applies middleware in order', async () => { + const middleware: AuthMiddleware[] = [ + { + async onRequest({request}) { + request.headers.set('x-first', 'first'); + return request; + }, + }, + { + async onRequest({request}) { + request.headers.set('x-second', 'second'); + return request; + }, + }, + ]; + + const request = new Request('https://example.com'); + const result = await applyAuthRequestMiddleware(request, middleware); + + expect(result.headers.get('x-first')).to.equal('first'); + expect(result.headers.get('x-second')).to.equal('second'); + }); + + it('skips middleware without onRequest', async () => { + const middleware: AuthMiddleware[] = [ + { + async onResponse() { + return undefined; + }, + }, + { + async onRequest({request}) { + request.headers.set('x-header', 'value'); + return request; + }, + }, + ]; + + const request = new Request('https://example.com'); + const result = await applyAuthRequestMiddleware(request, middleware); + + expect(result.headers.get('x-header')).to.equal('value'); + }); + + it('continues if middleware returns void', async () => { + const middleware: AuthMiddleware[] = [ + { + async onRequest() { + // Returns void (undefined) + }, + }, + { + async onRequest({request}) { + request.headers.set('x-header', 'value'); + return request; + }, + }, + ]; + + const request = new Request('https://example.com'); + const result = await applyAuthRequestMiddleware(request, middleware); + + expect(result.headers.get('x-header')).to.equal('value'); + }); + }); + + describe('applyAuthResponseMiddleware', () => { + it('applies middleware in order', async () => { + let order = ''; + const middleware: AuthMiddleware[] = [ + { + async onResponse({response}) { + order += 'first'; + return response; + }, + }, + { + async onResponse({response}) { + order += '-second'; + return response; + }, + }, + ]; + + const request = new Request('https://example.com'); + const response = new Response('body'); + await applyAuthResponseMiddleware(request, response, middleware); + + expect(order).to.equal('first-second'); + }); + + it('skips middleware without onResponse', async () => { + let called = false; + const middleware: AuthMiddleware[] = [ + { + async onRequest() { + return undefined; + }, + }, + { + async onResponse({response}) { + called = true; + return response; + }, + }, + ]; + + const request = new Request('https://example.com'); + const response = new Response('body'); + await applyAuthResponseMiddleware(request, response, middleware); + + expect(called).to.be.true; + }); + }); + + describe('globalAuthMiddlewareRegistry integration with OAuthStrategy', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + // Clear token cache between tests + const dummy = new OAuthStrategy({clientId: 'test-client', clientSecret: 'test-secret'}); + dummy.invalidateToken(); + }); + + after(() => { + server.close(); + }); + + it('applies User-Agent headers to OAuth token requests', async () => { + let capturedUserAgent: string | null = null; + let capturedSfdcUserAgent: string | null = null; + + server.use( + http.post(AM_URL, ({request}) => { + capturedUserAgent = request.headers.get('User-Agent'); + capturedSfdcUserAgent = request.headers.get('sfdc_user_agent'); + + const mockToken = createMockJWT({sub: 'test-client-ua'}); + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + scope: 'sfcc.sandbox.manage', + }); + }), + ); + + // The user-agent provider should already be registered via the module import + // Just verify the headers are present + const strategy = new OAuthStrategy({ + clientId: 'test-client-ua', + clientSecret: 'test-secret', + scopes: ['sfcc.sandbox.manage'], + }); + + await strategy.getTokenResponse(); + + // User-Agent headers should be set (the exact value depends on whether + // CLI has overridden it, but they should be present) + expect(capturedUserAgent).to.not.be.null; + expect(capturedSfdcUserAgent).to.not.be.null; + }); + + it('applies custom auth middleware', async () => { + let customHeaderValue: string | null = null; + + server.use( + http.post(AM_URL, ({request}) => { + customHeaderValue = request.headers.get('X-Custom-Auth-Header'); + + const mockToken = createMockJWT({sub: 'test-client-custom'}); + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + }); + }), + ); + + // Register custom middleware + const customProvider: AuthMiddlewareProvider = { + name: 'custom-test-middleware', + getMiddleware() { + return { + async onRequest({request}) { + request.headers.set('X-Custom-Auth-Header', 'custom-value'); + return request; + }, + }; + }, + }; + + globalAuthMiddlewareRegistry.register(customProvider); + + try { + const strategy = new OAuthStrategy({ + clientId: 'test-client-custom', + clientSecret: 'test-secret', + }); + + await strategy.getTokenResponse(); + + expect(customHeaderValue).to.equal('custom-value'); + } finally { + // Clean up + globalAuthMiddlewareRegistry.unregister('custom-test-middleware'); + } + }); + }); +}); + +/** + * Helper to create a mock JWT token + */ +function createMockJWT(payload: Record): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64'); + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64'); + return `${headerB64}.${payloadB64}.mock-signature`; +}