Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/Auth0.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/core/interfaces/IAuth0Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
44 changes: 40 additions & 4 deletions src/core/services/AuthenticationOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -368,8 +385,27 @@ export class AuthenticationOrchestrator implements IAuthenticationProvider {
}

async userInfo(parameters: UserInfoParameters): Promise<User> {
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<string, string>;

// 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<RawUser>(
'/userinfo',
undefined,
Expand Down
79 changes: 77 additions & 2 deletions src/core/services/HttpClient.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;

/**
* 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<string, string> {
return { Authorization: `${TokenType.bearer} ${token}` };
}

export interface HttpClientOptions {
baseUrl: string;
timeout?: number;
Expand Down Expand Up @@ -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<any> {
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',
};
}
}
Expand Down
58 changes: 47 additions & 11 deletions src/core/services/ManagementApiOrchestrator.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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<string, string> {
return {
Authorization: `Bearer ${this.token}`,
};
private async getRequestHeaders(
path: string,
method: string
): Promise<Record<string, string>> {
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);
}

/**
Expand All @@ -44,11 +78,12 @@ export class ManagementApiOrchestrator implements IUsersClient {

async getUser(parameters: GetUserParameters): Promise<User> {
const path = `/api/v2/users/${encodeURIComponent(parameters.id)}`;
const headers = await this.getRequestHeaders(path, 'GET');

const { json, response } = await this.client.get<any>(
path,
undefined, // No query parameters
this.getRequestHeaders()
headers
);

if (!response.ok) {
Expand All @@ -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<any>(
path,
body,
this.getRequestHeaders()
headers
);

if (!response.ok) {
Expand Down
Loading