Skip to content

Commit d17934d

Browse files
feat: Add DPoP support for /userinfo and Management API calls (#1398)
1 parent 7b1d1c5 commit d17934d

File tree

14 files changed

+709
-44
lines changed

14 files changed

+709
-44
lines changed

src/Auth0.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { IAuth0Client } from './core/interfaces/IAuth0Client';
2+
import type { TokenType } from './types/common';
23
import { Auth0ClientFactory } from './factory/Auth0ClientFactory';
34
import type { Auth0Options, DPoPHeadersParams } from './types';
45

@@ -57,9 +58,11 @@ class Auth0 {
5758

5859
/**
5960
* Provides access to the Management API (e.g., for user patching).
61+
* @param token An access token with the required permissions for the management operations.
62+
* @param tokenType Optional token type ('Bearer' or 'DPoP'). Defaults to the client's configured token type.
6063
*/
61-
users(token: string) {
62-
return this.client.users(token);
64+
users(token: string, tokenType?: TokenType) {
65+
return this.client.users(token, tokenType);
6366
}
6467

6568
/**

src/core/interfaces/IAuth0Client.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { IWebAuthProvider } from './IWebAuthProvider';
22
import type { ICredentialsManager } from './ICredentialsManager';
33
import type { IAuthenticationProvider } from './IAuthenticationProvider';
44
import type { IUsersClient } from './IUsersClient';
5-
import type { DPoPHeadersParams } from '../../types';
5+
import type { DPoPHeadersParams, TokenType } from '../../types';
66

77
/**
88
* The primary interface for the Auth0 client.
@@ -31,9 +31,10 @@ export interface IAuth0Client {
3131
* Creates a client for interacting with the Auth0 Management API's user endpoints.
3232
*
3333
* @param token An access token with the required permissions for the management operations.
34+
* @param tokenType Optional token type ('Bearer' or 'DPoP'). Defaults to the client's configured token type.
3435
* @returns An `IUsersClient` instance configured with the provided token.
3536
*/
36-
users(token: string): IUsersClient;
37+
users(token: string, tokenType?: TokenType): IUsersClient;
3738

3839
/**
3940
* Generates DPoP headers for making authenticated requests to custom APIs.

src/core/services/AuthenticationOrchestrator.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ import {
2929
AuthError,
3030
} from '../models';
3131
import { validateParameters } from '../utils/validation';
32-
import { HttpClient } from './HttpClient';
32+
import {
33+
HttpClient,
34+
getBearerHeader,
35+
type DPoPHeadersProvider,
36+
} from './HttpClient';
37+
import { TokenType } from '../../types/common';
3338
import { deepCamelCase } from '../utils';
3439

3540
// Represents the raw user profile returned by an API (snake_case)
@@ -66,10 +71,22 @@ function includeRequiredScope(scope?: string): string {
6671
export class AuthenticationOrchestrator implements IAuthenticationProvider {
6772
private readonly client: HttpClient;
6873
private readonly clientId: string;
74+
private readonly tokenType: TokenType;
75+
private readonly baseUrl: string;
76+
private readonly getDPoPHeaders?: DPoPHeadersProvider;
6977

70-
constructor(options: { clientId: string; httpClient: HttpClient }) {
78+
constructor(options: {
79+
clientId: string;
80+
httpClient: HttpClient;
81+
tokenType?: TokenType;
82+
baseUrl?: string;
83+
getDPoPHeaders?: DPoPHeadersProvider;
84+
}) {
7185
this.clientId = options.clientId;
7286
this.client = options.httpClient;
87+
this.tokenType = options.tokenType ?? TokenType.bearer;
88+
this.baseUrl = options.baseUrl ?? '';
89+
this.getDPoPHeaders = options.getDPoPHeaders;
7390
}
7491

7592
authorizeUrl(parameters: AuthorizeUrlParameters): string {
@@ -368,8 +385,27 @@ export class AuthenticationOrchestrator implements IAuthenticationProvider {
368385
}
369386

370387
async userInfo(parameters: UserInfoParameters): Promise<User> {
371-
const { token, headers } = parameters;
372-
const requestHeaders = { Authorization: `Bearer ${token}`, ...headers };
388+
const { token, tokenType: paramTokenType, headers } = parameters;
389+
390+
// Use parameter tokenType if provided, otherwise use client's default
391+
const effectiveTokenType = paramTokenType ?? this.tokenType;
392+
393+
let authHeader: Record<string, string>;
394+
395+
// For DPoP tokens, we need to generate a DPoP proof using the native layer
396+
if (effectiveTokenType === TokenType.dpop && this.getDPoPHeaders) {
397+
const userInfoUrl = `${this.baseUrl}/userinfo`;
398+
authHeader = await this.getDPoPHeaders({
399+
url: userInfoUrl,
400+
method: 'GET',
401+
accessToken: token,
402+
tokenType: TokenType.dpop,
403+
});
404+
} else {
405+
authHeader = getBearerHeader(token);
406+
}
407+
408+
const requestHeaders = { ...authHeader, ...headers };
373409
const { json, response } = await this.client.get<RawUser>(
374410
'/userinfo',
375411
undefined,

src/core/services/HttpClient.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
11
import { fetchWithTimeout, TimeoutError } from '../utils/fetchWithTimeout';
22
import { toUrlQueryParams } from '../utils';
33
import { AuthError } from '../models';
4+
import { TokenType } from '../../types/common';
45
import base64 from 'base-64';
56
import { telemetry } from '../utils/telemetry';
67

8+
/**
9+
* Function type for getting DPoP headers from the native/platform layer.
10+
*/
11+
export type DPoPHeadersProvider = (params: {
12+
url: string;
13+
method: string;
14+
accessToken: string;
15+
tokenType: string;
16+
nonce?: string;
17+
}) => Promise<Record<string, string>>;
18+
19+
/**
20+
* Returns the Bearer authentication header.
21+
* @param token - The token value
22+
* @returns A record with the Authorization header containing the Bearer token
23+
*/
24+
export function getBearerHeader(token: string): Record<string, string> {
25+
return { Authorization: `${TokenType.bearer} ${token}` };
26+
}
27+
728
export interface HttpClientOptions {
829
baseUrl: string;
930
timeout?: number;
@@ -70,23 +91,77 @@ export class HttpClient {
7091
return url;
7192
}
7293

94+
/**
95+
* Parses the WWW-Authenticate header to extract error information.
96+
* Per RFC 6750, OAuth 2.0 Bearer Token errors are returned in this header with format:
97+
* Bearer error="invalid_token", error_description="The access token expired"
98+
*
99+
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
100+
*/
101+
private parseWwwAuthenticateHeader(
102+
response: Response
103+
): { error: string; error_description?: string } | null {
104+
const wwwAuthenticate = response.headers.get('WWW-Authenticate');
105+
if (!wwwAuthenticate) {
106+
return null;
107+
}
108+
109+
// Parse key="value" pairs from the header
110+
// Matches: error="invalid_token", error_description="The access token expired"
111+
const errorMatch = wwwAuthenticate.match(/error="([^"]+)"/);
112+
const descriptionMatch = wwwAuthenticate.match(
113+
/error_description="([^"]+)"/
114+
);
115+
116+
if (errorMatch?.[1]) {
117+
return {
118+
error: errorMatch[1],
119+
error_description: descriptionMatch?.[1],
120+
};
121+
}
122+
123+
return null;
124+
}
125+
73126
/**
74127
* Safely parses a JSON response, handling cases where the body might be empty or invalid JSON.
75128
* This prevents "body already consumed" errors by reading text first, then parsing.
129+
*
130+
* For error responses (4xx/5xx), if the body is not valid JSON, we check the WWW-Authenticate
131+
* header for OAuth 2.0 Bearer token errors (RFC 6750), which is how endpoints like /userinfo
132+
* return errors.
76133
*/
77134
private async safeJson(response: Response): Promise<any> {
78135
if (response.status === 204) {
79136
// No Content
80137
return {};
81138
}
82-
let text = 'Failed to parse response body';
139+
140+
let text = '';
83141
try {
84142
text = await response.text();
85143
return JSON.parse(text);
86144
} catch {
145+
// For error responses, check WWW-Authenticate header (RFC 6750)
146+
// This is how OAuth 2.0 protected resources like /userinfo return errors
147+
if (!response.ok) {
148+
const wwwAuthError = this.parseWwwAuthenticateHeader(response);
149+
if (wwwAuthError) {
150+
return wwwAuthError;
151+
}
152+
153+
// Fallback: return a generic HTTP error with the status code
154+
return {
155+
error: `http_error_${response.status}`,
156+
error_description:
157+
text || response.statusText || `HTTP ${response.status} error`,
158+
};
159+
}
160+
161+
// For successful responses with invalid JSON, return invalid_json error
87162
return {
88163
error: 'invalid_json',
89-
error_description: text,
164+
error_description: text || 'Failed to parse response body',
90165
};
91166
}
92167
}

src/core/services/ManagementApiOrchestrator.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import type { IUsersClient } from '../interfaces/IUsersClient';
2-
import type { GetUserParameters, PatchUserParameters, User } from '../../types';
2+
import {
3+
TokenType,
4+
type GetUserParameters,
5+
type PatchUserParameters,
6+
type User,
7+
} from '../../types';
38
import { Auth0User, AuthError } from '../models';
4-
import { HttpClient } from '../services/HttpClient';
9+
import {
10+
HttpClient,
11+
getBearerHeader,
12+
type DPoPHeadersProvider,
13+
} from '../services/HttpClient';
514
import { deepCamelCase } from '../utils';
615

716
/**
@@ -10,20 +19,45 @@ import { deepCamelCase } from '../utils';
1019
export class ManagementApiOrchestrator implements IUsersClient {
1120
private readonly client: HttpClient;
1221
private readonly token: string;
22+
private readonly tokenType: TokenType;
23+
private readonly baseUrl: string;
24+
private readonly getDPoPHeaders?: DPoPHeadersProvider;
1325

14-
constructor(options: { token: string; httpClient: HttpClient }) {
26+
constructor(options: {
27+
token: string;
28+
httpClient: HttpClient;
29+
tokenType?: TokenType;
30+
baseUrl?: string;
31+
getDPoPHeaders?: DPoPHeadersProvider;
32+
}) {
1533
this.token = options.token;
1634
this.client = options.httpClient;
35+
this.tokenType = options.tokenType ?? TokenType.bearer;
36+
this.baseUrl = options.baseUrl ?? '';
37+
this.getDPoPHeaders = options.getDPoPHeaders;
1738
}
39+
1840
/**
1941
* Creates the specific headers required for Management API requests,
20-
* including the Bearer token.
21-
* @returns A record of headers for the request.
42+
* including the Bearer or DPoP token based on tokenType.
43+
* @param path - The API path (used to build full URL for DPoP proof generation)
44+
* @param method - The HTTP method (needed for DPoP proof generation)
45+
* @returns A promise that resolves to a record of headers for the request.
2246
*/
23-
private getRequestHeaders(): Record<string, string> {
24-
return {
25-
Authorization: `Bearer ${this.token}`,
26-
};
47+
private async getRequestHeaders(
48+
path: string,
49+
method: string
50+
): Promise<Record<string, string>> {
51+
if (this.tokenType === TokenType.dpop && this.getDPoPHeaders) {
52+
const fullUrl = `${this.baseUrl}${path}`;
53+
return this.getDPoPHeaders({
54+
url: fullUrl,
55+
method,
56+
accessToken: this.token,
57+
tokenType: TokenType.dpop,
58+
});
59+
}
60+
return getBearerHeader(this.token);
2761
}
2862

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

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

4883
const { json, response } = await this.client.get<any>(
4984
path,
5085
undefined, // No query parameters
51-
this.getRequestHeaders()
86+
headers
5287
);
5388

5489
if (!response.ok) {
@@ -64,11 +99,12 @@ export class ManagementApiOrchestrator implements IUsersClient {
6499
const body = {
65100
user_metadata: parameters.metadata,
66101
};
102+
const headers = await this.getRequestHeaders(path, 'PATCH');
67103

68104
const { json, response } = await this.client.patch<any>(
69105
path,
70106
body,
71-
this.getRequestHeaders()
107+
headers
72108
);
73109

74110
if (!response.ok) {

0 commit comments

Comments
 (0)