diff --git a/src/Auth0.ts b/src/Auth0.ts index b9ca90b7..d9276368 100644 --- a/src/Auth0.ts +++ b/src/Auth0.ts @@ -1,4 +1,5 @@ import type { IAuth0Client } from './core/interfaces/IAuth0Client'; +import type { TokenType } from './types/common'; import { Auth0ClientFactory } from './factory/Auth0ClientFactory'; import type { Auth0Options, DPoPHeadersParams } from './types'; @@ -57,9 +58,11 @@ class Auth0 { /** * Provides access to the Management API (e.g., for user patching). + * @param token An access token with the required permissions for the management operations. + * @param tokenType Optional token type ('Bearer' or 'DPoP'). Defaults to the client's configured token type. */ - users(token: string) { - return this.client.users(token); + users(token: string, tokenType?: TokenType) { + return this.client.users(token, tokenType); } /** diff --git a/src/core/interfaces/IAuth0Client.ts b/src/core/interfaces/IAuth0Client.ts index cf465dcc..db42feab 100644 --- a/src/core/interfaces/IAuth0Client.ts +++ b/src/core/interfaces/IAuth0Client.ts @@ -2,7 +2,7 @@ import type { IWebAuthProvider } from './IWebAuthProvider'; import type { ICredentialsManager } from './ICredentialsManager'; import type { IAuthenticationProvider } from './IAuthenticationProvider'; import type { IUsersClient } from './IUsersClient'; -import type { DPoPHeadersParams } from '../../types'; +import type { DPoPHeadersParams, TokenType } from '../../types'; /** * The primary interface for the Auth0 client. @@ -31,9 +31,10 @@ export interface IAuth0Client { * Creates a client for interacting with the Auth0 Management API's user endpoints. * * @param token An access token with the required permissions for the management operations. + * @param tokenType Optional token type ('Bearer' or 'DPoP'). Defaults to the client's configured token type. * @returns An `IUsersClient` instance configured with the provided token. */ - users(token: string): IUsersClient; + users(token: string, tokenType?: TokenType): IUsersClient; /** * Generates DPoP headers for making authenticated requests to custom APIs. diff --git a/src/core/services/AuthenticationOrchestrator.ts b/src/core/services/AuthenticationOrchestrator.ts index 0f0d0538..041ad2f2 100644 --- a/src/core/services/AuthenticationOrchestrator.ts +++ b/src/core/services/AuthenticationOrchestrator.ts @@ -29,7 +29,12 @@ import { AuthError, } from '../models'; import { validateParameters } from '../utils/validation'; -import { HttpClient } from './HttpClient'; +import { + HttpClient, + getBearerHeader, + type DPoPHeadersProvider, +} from './HttpClient'; +import { TokenType } from '../../types/common'; import { deepCamelCase } from '../utils'; // Represents the raw user profile returned by an API (snake_case) @@ -66,10 +71,22 @@ function includeRequiredScope(scope?: string): string { export class AuthenticationOrchestrator implements IAuthenticationProvider { private readonly client: HttpClient; private readonly clientId: string; + private readonly tokenType: TokenType; + private readonly baseUrl: string; + private readonly getDPoPHeaders?: DPoPHeadersProvider; - constructor(options: { clientId: string; httpClient: HttpClient }) { + constructor(options: { + clientId: string; + httpClient: HttpClient; + tokenType?: TokenType; + baseUrl?: string; + getDPoPHeaders?: DPoPHeadersProvider; + }) { this.clientId = options.clientId; this.client = options.httpClient; + this.tokenType = options.tokenType ?? TokenType.bearer; + this.baseUrl = options.baseUrl ?? ''; + this.getDPoPHeaders = options.getDPoPHeaders; } authorizeUrl(parameters: AuthorizeUrlParameters): string { @@ -368,8 +385,27 @@ export class AuthenticationOrchestrator implements IAuthenticationProvider { } async userInfo(parameters: UserInfoParameters): Promise { - const { token, headers } = parameters; - const requestHeaders = { Authorization: `Bearer ${token}`, ...headers }; + const { token, tokenType: paramTokenType, headers } = parameters; + + // Use parameter tokenType if provided, otherwise use client's default + const effectiveTokenType = paramTokenType ?? this.tokenType; + + let authHeader: Record; + + // For DPoP tokens, we need to generate a DPoP proof using the native layer + if (effectiveTokenType === TokenType.dpop && this.getDPoPHeaders) { + const userInfoUrl = `${this.baseUrl}/userinfo`; + authHeader = await this.getDPoPHeaders({ + url: userInfoUrl, + method: 'GET', + accessToken: token, + tokenType: TokenType.dpop, + }); + } else { + authHeader = getBearerHeader(token); + } + + const requestHeaders = { ...authHeader, ...headers }; const { json, response } = await this.client.get( '/userinfo', undefined, diff --git a/src/core/services/HttpClient.ts b/src/core/services/HttpClient.ts index ff86f988..6701d86c 100644 --- a/src/core/services/HttpClient.ts +++ b/src/core/services/HttpClient.ts @@ -1,9 +1,30 @@ import { fetchWithTimeout, TimeoutError } from '../utils/fetchWithTimeout'; import { toUrlQueryParams } from '../utils'; import { AuthError } from '../models'; +import { TokenType } from '../../types/common'; import base64 from 'base-64'; import { telemetry } from '../utils/telemetry'; +/** + * Function type for getting DPoP headers from the native/platform layer. + */ +export type DPoPHeadersProvider = (params: { + url: string; + method: string; + accessToken: string; + tokenType: string; + nonce?: string; +}) => Promise>; + +/** + * Returns the Bearer authentication header. + * @param token - The token value + * @returns A record with the Authorization header containing the Bearer token + */ +export function getBearerHeader(token: string): Record { + return { Authorization: `${TokenType.bearer} ${token}` }; +} + export interface HttpClientOptions { baseUrl: string; timeout?: number; @@ -70,23 +91,77 @@ export class HttpClient { return url; } + /** + * Parses the WWW-Authenticate header to extract error information. + * Per RFC 6750, OAuth 2.0 Bearer Token errors are returned in this header with format: + * Bearer error="invalid_token", error_description="The access token expired" + * + * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3 + */ + private parseWwwAuthenticateHeader( + response: Response + ): { error: string; error_description?: string } | null { + const wwwAuthenticate = response.headers.get('WWW-Authenticate'); + if (!wwwAuthenticate) { + return null; + } + + // Parse key="value" pairs from the header + // Matches: error="invalid_token", error_description="The access token expired" + const errorMatch = wwwAuthenticate.match(/error="([^"]+)"/); + const descriptionMatch = wwwAuthenticate.match( + /error_description="([^"]+)"/ + ); + + if (errorMatch?.[1]) { + return { + error: errorMatch[1], + error_description: descriptionMatch?.[1], + }; + } + + return null; + } + /** * Safely parses a JSON response, handling cases where the body might be empty or invalid JSON. * This prevents "body already consumed" errors by reading text first, then parsing. + * + * For error responses (4xx/5xx), if the body is not valid JSON, we check the WWW-Authenticate + * header for OAuth 2.0 Bearer token errors (RFC 6750), which is how endpoints like /userinfo + * return errors. */ private async safeJson(response: Response): Promise { if (response.status === 204) { // No Content return {}; } - let text = 'Failed to parse response body'; + + let text = ''; try { text = await response.text(); return JSON.parse(text); } catch { + // For error responses, check WWW-Authenticate header (RFC 6750) + // This is how OAuth 2.0 protected resources like /userinfo return errors + if (!response.ok) { + const wwwAuthError = this.parseWwwAuthenticateHeader(response); + if (wwwAuthError) { + return wwwAuthError; + } + + // Fallback: return a generic HTTP error with the status code + return { + error: `http_error_${response.status}`, + error_description: + text || response.statusText || `HTTP ${response.status} error`, + }; + } + + // For successful responses with invalid JSON, return invalid_json error return { error: 'invalid_json', - error_description: text, + error_description: text || 'Failed to parse response body', }; } } diff --git a/src/core/services/ManagementApiOrchestrator.ts b/src/core/services/ManagementApiOrchestrator.ts index 851a6d62..4e96b96c 100644 --- a/src/core/services/ManagementApiOrchestrator.ts +++ b/src/core/services/ManagementApiOrchestrator.ts @@ -1,7 +1,16 @@ import type { IUsersClient } from '../interfaces/IUsersClient'; -import type { GetUserParameters, PatchUserParameters, User } from '../../types'; +import { + TokenType, + type GetUserParameters, + type PatchUserParameters, + type User, +} from '../../types'; import { Auth0User, AuthError } from '../models'; -import { HttpClient } from '../services/HttpClient'; +import { + HttpClient, + getBearerHeader, + type DPoPHeadersProvider, +} from '../services/HttpClient'; import { deepCamelCase } from '../utils'; /** @@ -10,20 +19,45 @@ import { deepCamelCase } from '../utils'; export class ManagementApiOrchestrator implements IUsersClient { private readonly client: HttpClient; private readonly token: string; + private readonly tokenType: TokenType; + private readonly baseUrl: string; + private readonly getDPoPHeaders?: DPoPHeadersProvider; - constructor(options: { token: string; httpClient: HttpClient }) { + constructor(options: { + token: string; + httpClient: HttpClient; + tokenType?: TokenType; + baseUrl?: string; + getDPoPHeaders?: DPoPHeadersProvider; + }) { this.token = options.token; this.client = options.httpClient; + this.tokenType = options.tokenType ?? TokenType.bearer; + this.baseUrl = options.baseUrl ?? ''; + this.getDPoPHeaders = options.getDPoPHeaders; } + /** * Creates the specific headers required for Management API requests, - * including the Bearer token. - * @returns A record of headers for the request. + * including the Bearer or DPoP token based on tokenType. + * @param path - The API path (used to build full URL for DPoP proof generation) + * @param method - The HTTP method (needed for DPoP proof generation) + * @returns A promise that resolves to a record of headers for the request. */ - private getRequestHeaders(): Record { - return { - Authorization: `Bearer ${this.token}`, - }; + private async getRequestHeaders( + path: string, + method: string + ): Promise> { + if (this.tokenType === TokenType.dpop && this.getDPoPHeaders) { + const fullUrl = `${this.baseUrl}${path}`; + return this.getDPoPHeaders({ + url: fullUrl, + method, + accessToken: this.token, + tokenType: TokenType.dpop, + }); + } + return getBearerHeader(this.token); } /** @@ -44,11 +78,12 @@ export class ManagementApiOrchestrator implements IUsersClient { async getUser(parameters: GetUserParameters): Promise { const path = `/api/v2/users/${encodeURIComponent(parameters.id)}`; + const headers = await this.getRequestHeaders(path, 'GET'); const { json, response } = await this.client.get( path, undefined, // No query parameters - this.getRequestHeaders() + headers ); if (!response.ok) { @@ -64,11 +99,12 @@ export class ManagementApiOrchestrator implements IUsersClient { const body = { user_metadata: parameters.metadata, }; + const headers = await this.getRequestHeaders(path, 'PATCH'); const { json, response } = await this.client.patch( path, body, - this.getRequestHeaders() + headers ); if (!response.ok) { diff --git a/src/core/services/__tests__/AuthenticationOrchestrator.spec.ts b/src/core/services/__tests__/AuthenticationOrchestrator.spec.ts index 9b74f1d6..40c33193 100644 --- a/src/core/services/__tests__/AuthenticationOrchestrator.spec.ts +++ b/src/core/services/__tests__/AuthenticationOrchestrator.spec.ts @@ -6,8 +6,34 @@ import { Credentials as CredentialsModel, } from '../../models'; -// 1. Mock the HttpClient. We only need to mock the methods we use. -jest.mock('../HttpClient'); +// Mock HttpClient but preserve getAuthHeader +jest.mock('../HttpClient', () => { + const actual = jest.requireActual('../HttpClient'); + return { + ...actual, + HttpClient: jest.fn().mockImplementation(() => ({ + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + buildUrl: jest.fn((path: string, query?: Record) => { + let url = `https://samples.auth0.com${path}`; + if (query) { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + params.append(key, String(value)); + } + }); + const queryString = params.toString(); + if (queryString) { + url += `?${queryString}`; + } + } + return url; + }), + })), + }; +}); const MockHttpClient = HttpClient as jest.MockedClass; // 2. Define test data, mirroring the old auth.spec.js file. @@ -44,7 +70,12 @@ const userProfileResponse = { describe('AuthenticationOrchestrator', () => { let orchestrator: AuthenticationOrchestrator; - let mockHttpClientInstance: jest.Mocked; + let mockHttpClientInstance: { + get: jest.Mock; + post: jest.Mock; + patch: jest.Mock; + buildUrl: jest.Mock; + }; beforeAll(() => { // Set a fixed date for consistent `expiresAt` calculations @@ -57,12 +88,35 @@ describe('AuthenticationOrchestrator', () => { beforeEach(() => { // Reset mocks and create new instances for each test + mockHttpClientInstance = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + buildUrl: jest.fn((path: string, query?: Record) => { + let url = `${baseUrl}${path}`; + if (query) { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + params.append(key, String(value)); + } + }); + const queryString = params.toString(); + if (queryString) { + url += `?${queryString}`; + } + } + return url; + }), + }; MockHttpClient.mockClear(); + (MockHttpClient as jest.Mock).mockImplementation( + () => mockHttpClientInstance + ); orchestrator = new AuthenticationOrchestrator({ clientId, - httpClient: new MockHttpClient({ baseUrl }), + httpClient: mockHttpClientInstance as unknown as HttpClient, }); - mockHttpClientInstance = MockHttpClient.mock.instances[0]; }); // Note: Constructor and URL builder tests are not applicable here. diff --git a/src/core/services/__tests__/HttpClient.spec.ts b/src/core/services/__tests__/HttpClient.spec.ts new file mode 100644 index 00000000..e57f57f5 --- /dev/null +++ b/src/core/services/__tests__/HttpClient.spec.ts @@ -0,0 +1,307 @@ +import { HttpClient, getBearerHeader } from '../HttpClient'; + +// Mock the telemetry module +jest.mock('../../utils/telemetry', () => ({ + telemetry: { name: 'test', version: '1.0.0' }, +})); + +describe('HttpClient', () => { + const baseUrl = 'https://test.auth0.com'; + let httpClient: HttpClient; + + beforeEach(() => { + httpClient = new HttpClient({ baseUrl }); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('getBearerHeader', () => { + it('should return Bearer header', () => { + const result = getBearerHeader('test-token'); + expect(result).toEqual({ Authorization: 'Bearer test-token' }); + }); + }); + + describe('safeJson - WWW-Authenticate header parsing', () => { + it('should parse error from WWW-Authenticate header on 401 response', async () => { + const mockResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + text: jest.fn().mockResolvedValue(''), + headers: new Map([ + [ + 'WWW-Authenticate', + 'Bearer error="invalid_token", error_description="The access token expired"', + ], + ]) as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn((name: string) => { + if (name === 'WWW-Authenticate') { + return 'Bearer error="invalid_token", error_description="The access token expired"'; + } + return null; + }); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/userinfo'); + + expect(json).toEqual({ + error: 'invalid_token', + error_description: 'The access token expired', + }); + }); + + it('should parse error without description from WWW-Authenticate header', async () => { + const mockResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + text: jest.fn().mockResolvedValue(''), + headers: new Map([ + ['WWW-Authenticate', 'Bearer error="invalid_token"'], + ]) as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn((name: string) => { + if (name === 'WWW-Authenticate') { + return 'Bearer error="invalid_token"'; + } + return null; + }); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/userinfo'); + + expect(json).toEqual({ + error: 'invalid_token', + error_description: undefined, + }); + }); + + it('should fallback to http_error_401 when WWW-Authenticate has no error', async () => { + const mockResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + text: jest.fn().mockResolvedValue(''), + headers: new Map([ + ['WWW-Authenticate', 'Bearer'], + ]) as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn((name: string) => { + if (name === 'WWW-Authenticate') { + return 'Bearer'; + } + return null; + }); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/userinfo'); + + expect(json).toEqual({ + error: 'http_error_401', + error_description: 'Unauthorized', + }); + }); + + it('should fallback to http_error when no WWW-Authenticate header', async () => { + const mockResponse = { + ok: false, + status: 403, + statusText: 'Forbidden', + text: jest.fn().mockResolvedValue('Access denied'), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn(() => null); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/some-endpoint'); + + expect(json).toEqual({ + error: 'http_error_403', + error_description: 'Access denied', + }); + }); + + it('should use statusText when response body is empty', async () => { + const mockResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: jest.fn().mockResolvedValue(''), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn(() => null); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/some-endpoint'); + + expect(json).toEqual({ + error: 'http_error_500', + error_description: 'Internal Server Error', + }); + }); + + it('should return invalid_json for successful response with invalid JSON', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + text: jest.fn().mockResolvedValue('not valid json'), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn(() => null); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/some-endpoint'); + + expect(json).toEqual({ + error: 'invalid_json', + error_description: 'not valid json', + }); + }); + + it('should parse valid JSON response correctly', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + text: jest + .fn() + .mockResolvedValue('{"sub": "user123", "name": "Test User"}'), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn(() => null); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/userinfo'); + + expect(json).toEqual({ + sub: 'user123', + name: 'Test User', + }); + }); + + it('should return empty object for 204 No Content', async () => { + const mockResponse = { + ok: true, + status: 204, + statusText: 'No Content', + text: jest.fn(), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn(() => null); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/some-endpoint'); + + expect(json).toEqual({}); + expect(mockResponse.text).not.toHaveBeenCalled(); + }); + + it('should parse JSON error response from body when available', async () => { + const mockResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + text: jest + .fn() + .mockResolvedValue( + '{"error": "invalid_request", "error_description": "Missing parameter"}' + ), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn(() => null); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/oauth/token'); + + expect(json).toEqual({ + error: 'invalid_request', + error_description: 'Missing parameter', + }); + }); + + it('should handle insufficient_scope error in WWW-Authenticate', async () => { + const mockResponse = { + ok: false, + status: 403, + statusText: 'Forbidden', + text: jest.fn().mockResolvedValue(''), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn((name: string) => { + if (name === 'WWW-Authenticate') { + return 'Bearer error="insufficient_scope", error_description="The request requires higher privileges"'; + } + return null; + }); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/api/admin'); + + expect(json).toEqual({ + error: 'insufficient_scope', + error_description: 'The request requires higher privileges', + }); + }); + + it('should handle invalid_request error in WWW-Authenticate', async () => { + const mockResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + text: jest.fn().mockResolvedValue(''), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn((name: string) => { + if (name === 'WWW-Authenticate') { + return 'Bearer error="invalid_request", error_description="The request is missing a required parameter"'; + } + return null; + }); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/userinfo'); + + expect(json).toEqual({ + error: 'invalid_request', + error_description: 'The request is missing a required parameter', + }); + }); + + it('should use text body as description when WWW-Authenticate is not present', async () => { + const mockResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + text: jest.fn().mockResolvedValue('Token has expired'), + headers: new Map() as unknown as Headers, + } as unknown as Response; + mockResponse.headers.get = jest.fn(() => null); + + global.fetch = jest.fn().mockResolvedValue(mockResponse); + + const { json } = await httpClient.get('/userinfo'); + + expect(json).toEqual({ + error: 'http_error_401', + error_description: 'Token has expired', + }); + }); + }); +}); diff --git a/src/core/services/__tests__/ManagementApiOrchestrator.spec.ts b/src/core/services/__tests__/ManagementApiOrchestrator.spec.ts index 2d805936..f469a2a0 100644 --- a/src/core/services/__tests__/ManagementApiOrchestrator.spec.ts +++ b/src/core/services/__tests__/ManagementApiOrchestrator.spec.ts @@ -2,10 +2,21 @@ import { ManagementApiOrchestrator } from '../ManagementApiOrchestrator'; import { HttpClient } from '../HttpClient'; import { AuthError, Auth0User } from '../../models'; -jest.mock('../HttpClient'); +// Mock HttpClient but preserve getBearerHeader +jest.mock('../HttpClient', () => { + const actual = jest.requireActual('../HttpClient'); + return { + ...actual, + HttpClient: jest.fn().mockImplementation(() => ({ + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + buildUrl: jest.fn(), + })), + }; +}); const MockHttpClient = HttpClient as jest.MockedClass; -const baseUrl = 'https://samples.auth0.com'; const managementToken = 'a.management.api.token'; const userId = 'auth0|53b995f8bce68d9fc900099c'; @@ -34,14 +45,27 @@ const managementApiErrorResponse = { describe('ManagementApiOrchestrator', () => { let orchestrator: ManagementApiOrchestrator; - let mockHttpClientInstance: jest.Mocked; + let mockHttpClientInstance: { + get: jest.Mock; + post: jest.Mock; + patch: jest.Mock; + buildUrl: jest.Mock; + }; beforeEach(() => { jest.clearAllMocks(); - mockHttpClientInstance = new MockHttpClient({ baseUrl }); + mockHttpClientInstance = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + buildUrl: jest.fn(), + }; + (MockHttpClient as jest.Mock).mockImplementation( + () => mockHttpClientInstance + ); orchestrator = new ManagementApiOrchestrator({ token: managementToken, - httpClient: mockHttpClientInstance, + httpClient: mockHttpClientInstance as unknown as HttpClient, }); }); diff --git a/src/index.ts b/src/index.ts index ed873e40..442d3c5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export { DPoPError, } from './core/models'; export { TimeoutError } from './core/utils/fetchWithTimeout'; +export { TokenType } from './types/common'; export { Auth0Provider } from './hooks/Auth0Provider'; export { useAuth0 } from './hooks/useAuth0'; export * from './types'; diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts index ead5bfa9..91803ecc 100644 --- a/src/platforms/native/adapters/NativeAuth0Client.ts +++ b/src/platforms/native/adapters/NativeAuth0Client.ts @@ -13,6 +13,7 @@ import { ManagementApiOrchestrator, } from '../../../core/services'; import { HttpClient } from '../../../core/services/HttpClient'; +import { TokenType } from '../../../types/common'; import { AuthError, DPoPError } from '../../../core/models'; export class NativeAuth0Client implements IAuth0Client { @@ -21,21 +22,44 @@ export class NativeAuth0Client implements IAuth0Client { readonly auth: IAuthenticationProvider; private ready: Promise; private readonly httpClient: HttpClient; + private readonly tokenType: TokenType; private readonly bridge: INativeBridge; + private readonly baseUrl: string; + private readonly getDPoPHeadersForOrchestrator?: ( + params: DPoPHeadersParams + ) => Promise>; constructor(options: NativeAuth0Options) { const baseUrl = `https://${options.domain}`; + this.baseUrl = baseUrl; + const useDPoP = options.useDPoP ?? true; + this.tokenType = useDPoP ? TokenType.dpop : TokenType.bearer; + this.httpClient = new HttpClient({ baseUrl: baseUrl, timeout: options.timeout, headers: options.headers, }); + + const bridge = new NativeBridgeManager(); + this.bridge = bridge; + + // Create a bound getDPoPHeaders function for the orchestrator + const getDPoPHeadersForOrchestrator = async (params: DPoPHeadersParams) => { + await this.ready; + return this.bridge.getDPoPHeaders(params); + }; + this.getDPoPHeadersForOrchestrator = useDPoP + ? getDPoPHeadersForOrchestrator + : undefined; + this.auth = new AuthenticationOrchestrator({ clientId: options.clientId, httpClient: this.httpClient, + tokenType: this.tokenType, + baseUrl: baseUrl, + getDPoPHeaders: useDPoP ? getDPoPHeadersForOrchestrator : undefined, }); - const bridge = new NativeBridgeManager(); - this.bridge = bridge; this.ready = this.initialize(bridge, options); @@ -67,10 +91,21 @@ export class NativeAuth0Client implements IAuth0Client { } } - users(token: string): IUsersClient { + users(token: string, tokenType?: TokenType): IUsersClient { + // Use provided tokenType or fall back to client's default + const effectiveTokenType = tokenType ?? this.tokenType; + // Only provide getDPoPHeaders if the effective token type is DPoP + const getDPoPHeaders = + effectiveTokenType === TokenType.dpop + ? this.getDPoPHeadersForOrchestrator + : undefined; + return new ManagementApiOrchestrator({ token: token, httpClient: this.httpClient, + tokenType: effectiveTokenType, + baseUrl: this.baseUrl, + getDPoPHeaders, }); } diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts index ad365df4..394b3fbc 100644 --- a/src/platforms/web/adapters/WebAuth0Client.ts +++ b/src/platforms/web/adapters/WebAuth0Client.ts @@ -13,6 +13,7 @@ import { ManagementApiOrchestrator, } from '../../../core/services'; import { HttpClient } from '../../../core/services/HttpClient'; +import { TokenType } from '../../../types/common'; import { AuthError, DPoPError } from '../../../core/models'; export class WebAuth0Client implements IAuth0Client { @@ -21,6 +22,11 @@ export class WebAuth0Client implements IAuth0Client { readonly auth: AuthenticationOrchestrator; private readonly httpClient: HttpClient; + private readonly tokenType: TokenType; + private readonly baseUrl: string; + private readonly getDPoPHeadersForOrchestrator?: ( + params: DPoPHeadersParams + ) => Promise>; public readonly client: Auth0Client; private static spaClient: Auth0Client | null = null; @@ -51,6 +57,9 @@ export class WebAuth0Client implements IAuth0Client { constructor(options: WebAuth0Options) { const baseUrl = `https://${options.domain}`; + this.baseUrl = baseUrl; + const useDPoP = options.useDPoP ?? true; + this.tokenType = useDPoP ? TokenType.dpop : TokenType.bearer; this.httpClient = new HttpClient({ baseUrl: baseUrl, @@ -58,11 +67,6 @@ export class WebAuth0Client implements IAuth0Client { headers: options.headers, }); - this.auth = new AuthenticationOrchestrator({ - clientId: options.clientId, - httpClient: this.httpClient, - }); - const clientOptions: Auth0ClientOptions = { domain: options.domain, clientId: options.clientId, @@ -83,14 +87,41 @@ export class WebAuth0Client implements IAuth0Client { const client = WebAuth0Client.getSpaClient(clientOptions); this.client = client; + // Create a bound getDPoPHeaders function for the orchestrator + const getDPoPHeadersForOrchestrator = async (params: DPoPHeadersParams) => { + return this.getDPoPHeaders(params); + }; + this.getDPoPHeadersForOrchestrator = useDPoP + ? getDPoPHeadersForOrchestrator + : undefined; + + this.auth = new AuthenticationOrchestrator({ + clientId: options.clientId, + httpClient: this.httpClient, + tokenType: this.tokenType, + baseUrl: baseUrl, + getDPoPHeaders: useDPoP ? getDPoPHeadersForOrchestrator : undefined, + }); + this.webAuth = new WebWebAuthProvider(this.client); this.credentialsManager = new WebCredentialsManager(this.client); } - users(token: string): IUsersClient { + users(token: string, tokenType?: TokenType): IUsersClient { + // Use provided tokenType or fall back to client's default + const effectiveTokenType = tokenType ?? this.tokenType; + // Only provide getDPoPHeaders if the effective token type is DPoP + const getDPoPHeaders = + effectiveTokenType === TokenType.dpop + ? this.getDPoPHeadersForOrchestrator + : undefined; + return new ManagementApiOrchestrator({ token: token, httpClient: this.httpClient, + tokenType: effectiveTokenType, + baseUrl: this.baseUrl, + getDPoPHeaders, }); } diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts index c50a202e..8c9088e6 100644 --- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts @@ -116,10 +116,14 @@ describe('WebAuth0Client', () => { headers: undefined, }); - expect(MockAuthenticationOrchestrator).toHaveBeenCalledWith({ - clientId: defaultOptions.clientId, - httpClient: mockHttpClient, - }); + expect(MockAuthenticationOrchestrator).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: defaultOptions.clientId, + httpClient: mockHttpClient, + tokenType: 'DPoP', + baseUrl: `https://${defaultOptions.domain}`, + }) + ); expect(MockWebWebAuthProvider).toHaveBeenCalledWith(mockSpaClient); expect(MockWebCredentialsManager).toHaveBeenCalledWith(mockSpaClient); @@ -182,6 +186,9 @@ describe('WebAuth0Client', () => { expect(MockManagementApiOrchestrator).toHaveBeenCalledWith({ token, httpClient: mockHttpClient, + tokenType: 'DPoP', + baseUrl: `https://${defaultOptions.domain}`, + getDPoPHeaders: expect.any(Function), }); expect(usersClient).toBeDefined(); }); @@ -199,10 +206,16 @@ describe('WebAuth0Client', () => { expect(MockManagementApiOrchestrator).toHaveBeenNthCalledWith(1, { token: token1, httpClient: mockHttpClient, + tokenType: 'DPoP', + baseUrl: `https://${defaultOptions.domain}`, + getDPoPHeaders: expect.any(Function), }); expect(MockManagementApiOrchestrator).toHaveBeenNthCalledWith(2, { token: token2, httpClient: mockHttpClient, + tokenType: 'DPoP', + baseUrl: `https://${defaultOptions.domain}`, + getDPoPHeaders: expect.any(Function), }); }); }); diff --git a/src/types/common.ts b/src/types/common.ts index 0a62681b..512a0452 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -143,6 +143,48 @@ export type MfaChallengeResponse = // ========= DPoP Types ========= +/** + * Represents the type of access token used for API authentication. + * + * This enum provides type-safe constants for token types returned by Auth0 + * and used when making authenticated API requests. + * + * @remarks + * - `TokenType.bearer` - Standard OAuth 2.0 Bearer token (default) + * - `TokenType.dpop` - Demonstrating Proof-of-Possession (DPoP) bound token + * + * @example + * ```typescript + * import { TokenType } from 'react-native-auth0'; + * + * // Check if credentials use DPoP + * if (credentials.tokenType === TokenType.dpop) { + * const headers = await auth0.getDPoPHeaders({ + * url: 'https://api.example.com/data', + * method: 'GET', + * accessToken: credentials.accessToken, + * tokenType: credentials.tokenType + * }); + * } + * ``` + * + * @public + */ +export enum TokenType { + /** + * Standard OAuth 2.0 Bearer token authentication. + * This is the default token type used by most OAuth 2.0 implementations. + */ + bearer = 'Bearer', + /** + * Demonstrating Proof-of-Possession (DPoP) token authentication. + * DPoP tokens are sender-constrained, providing additional security + * by cryptographically binding the token to the client. + * @see {@link https://datatracker.ietf.org/doc/html/rfc9449 | RFC 9449} + */ + dpop = 'DPoP', +} + /** * Parameters required to generate DPoP headers for custom API requests. * These headers cryptographically bind the access token to the specific HTTP request. diff --git a/src/types/parameters.ts b/src/types/parameters.ts index 8aef8fc8..05a35956 100644 --- a/src/types/parameters.ts +++ b/src/types/parameters.ts @@ -1,3 +1,5 @@ +import type { TokenType } from './common'; + /** A base interface for API calls that allow passing custom headers. * @hidden */ @@ -214,6 +216,11 @@ export interface MfaChallengeParameters extends RequestOptions { /** Parameters for accessing the `/userinfo` endpoint. */ export interface UserInfoParameters extends RequestOptions { token: string; + /** + * The type of the token. When 'DPoP', DPoP headers will be generated automatically. + * Defaults to the client's configured token type. + */ + tokenType?: TokenType; } /** Parameters for requesting a password reset email. */