diff --git a/src/index.ts b/src/index.ts
index 3febe1c24..aa3c7fb86 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,2 +1,3 @@
export * from './management/index.js';
export * from './auth/index.js';
+export * from './userinfo/index.js';
diff --git a/src/userinfo/index.ts b/src/userinfo/index.ts
new file mode 100644
index 000000000..24529e95e
--- /dev/null
+++ b/src/userinfo/index.ts
@@ -0,0 +1,120 @@
+import { ResponseError } from '../lib/errors.js';
+import { TelemetryMiddleware } from '../lib/middleware/telemetry-middleware.js';
+import { ClientOptions, InitOverride, JSONApiResponse } from '../lib/models.js';
+import { BaseAPI } from '../lib/runtime.js';
+
+export interface UserInfoResponse {
+ sub: string;
+ name: string;
+ given_name?: string;
+ family_name?: string;
+ middle_name?: string;
+ nickname: string;
+ preferred_username?: string;
+ profile?: string;
+ picture?: string;
+ website?: string;
+ email: string;
+ email_verified: boolean;
+ gender?: string;
+ birthdate?: string;
+ zoneinfo?: string;
+ locale?: string;
+ phone_number?: string;
+ phone_number_verified?: string;
+ address?: {
+ country?: string;
+ };
+ updated_at: string;
+ [key: string]: unknown;
+}
+
+interface UserInfoErrorResponse {
+ error_description: string;
+ error: string;
+}
+
+export class UserInfoError extends Error {
+ override name = 'UserInfoError' as const;
+ constructor(
+ public error: string,
+ public error_description: string,
+ public statusCode: number,
+ public body: string,
+ public headers: Headers
+ ) {
+ super(error_description || error);
+ }
+}
+
+export async function parseError(response: Response) {
+ // Errors typically have a specific format:
+ // {
+ // error: 'invalid_body',
+ // error_description: 'Bad Request',
+ // }
+
+ const body = await response.text();
+ let data: UserInfoErrorResponse;
+
+ try {
+ data = JSON.parse(body) as UserInfoErrorResponse;
+ return new UserInfoError(
+ data.error,
+ data.error_description,
+ response.status,
+ body,
+ response.headers
+ );
+ } catch (_) {
+ return new ResponseError(
+ response.status,
+ body,
+ response.headers,
+ 'Response returned an error code'
+ );
+ }
+}
+
+export class UserInfoClient extends BaseAPI {
+ constructor(options: { domain: string } & ClientOptions) {
+ super({
+ ...options,
+ baseUrl: `https://${options.domain}`,
+ middleware: [
+ ...(options.middleware || []),
+ ...(options.telemetry !== false ? [new TelemetryMiddleware(options)] : []),
+ ],
+ parseError,
+ });
+ }
+
+ /**
+ * Given an access token get the user profile linked to it.
+ *
+ * @example
+ * Get the user information based on the Auth0 access token (obtained during
+ * login). Find more information in the
+ * API Docs.
+ *
+ *
+ * const userInfo = await auth0.users.getUserInfo(accessToken);
+ */
+ async getUserInfo(
+ accessToken: string,
+ initOverrides?: InitOverride
+ ): Promise> {
+ const response = await this.request(
+ {
+ path: `/userinfo`,
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ initOverrides
+ );
+
+ return JSONApiResponse.fromResponse(response);
+ }
+}
diff --git a/test/runtime/runtime.test.ts b/test/runtime/runtime.test.ts
index f4d42848e..42aae0c4e 100644
--- a/test/runtime/runtime.test.ts
+++ b/test/runtime/runtime.test.ts
@@ -1,6 +1,6 @@
import nock from 'nock';
import { jest } from '@jest/globals';
-import { AuthenticationClient, ManagementClient } from '../../src';
+import { AuthenticationClient, ManagementClient, UserInfoClient, UserInfoError } from '../../src';
import { ResponseError } from '../../src/lib/errors';
import { ErrorContext, InitOverrideFunction, RequestOpts } from '../../src/lib/models';
import { BaseAPI } from '../../src/lib/runtime';
@@ -696,3 +696,104 @@ describe('Runtime for AuthenticationClient', () => {
expect(request.isDone()).toBe(true);
});
});
+
+describe('Runtime for UserInfoClient', () => {
+ const URL = 'https://tenant.auth0.com';
+
+ afterEach(() => {
+ nock.cleanAll();
+ jest.clearAllMocks();
+ });
+
+ it('should throw a ResponseError when response does not provide payload', async () => {
+ nock(URL, { encodedQueryParams: true }).get('/userinfo').reply(428);
+
+ const client = new UserInfoClient({
+ domain: 'tenant.auth0.com',
+ });
+
+ try {
+ await client.getUserInfo('token');
+ // Should not reach this
+ expect(true).toBeFalsy();
+ } catch (e: any) {
+ if (e instanceof ResponseError) {
+ expect(e.statusCode).toBe(428);
+ } else {
+ expect(e).toBeInstanceOf(ResponseError);
+ }
+ }
+ });
+
+ it('should throw a UserInfoApiError when backend provides known error details', async () => {
+ nock(URL, { encodedQueryParams: true })
+ .get('/userinfo')
+ .reply(428, { error: 'test error', error_description: 'test error description' });
+
+ const client = new UserInfoClient({
+ domain: 'tenant.auth0.com',
+ });
+
+ try {
+ await client.getUserInfo('token');
+ // Should not reach this
+ expect(true).toBeFalsy();
+ } catch (e: any) {
+ if (e instanceof UserInfoError) {
+ expect(e.error).toBe('test error');
+ expect(e.error_description).toBe('test error description');
+ expect(e.message).toBe('test error description');
+ } else {
+ expect(e).toBeInstanceOf(UserInfoError);
+ }
+ }
+ });
+
+ it('should add the telemetry by default', async () => {
+ const request = nock(URL)
+ .get('/userinfo')
+ .matchHeader('Auth0-Client', base64url.encode(JSON.stringify(utils.generateClientInfo())))
+ .reply(200, {});
+
+ const client = new UserInfoClient({
+ domain: 'tenant.auth0.com',
+ });
+
+ await client.getUserInfo('token');
+
+ expect(request.isDone()).toBe(true);
+ });
+
+ it('should add custom telemetry when provided', async () => {
+ const mockClientInfo = { name: 'test', version: '12', env: { node: '16' } };
+
+ const request = nock(URL)
+ .get('/userinfo')
+ .matchHeader('Auth0-Client', base64url.encode(JSON.stringify(mockClientInfo)))
+ .reply(200, {});
+
+ const client = new UserInfoClient({
+ domain: 'tenant.auth0.com',
+ clientInfo: mockClientInfo,
+ });
+
+ await client.getUserInfo('token');
+
+ expect(request.isDone()).toBe(true);
+ });
+
+ it('should not add the telemetry when disabled', async () => {
+ const request = nock(URL, { badheaders: ['Auth0-Client'] })
+ .get('/userinfo')
+ .reply(200, {});
+
+ const client = new UserInfoClient({
+ domain: 'tenant.auth0.com',
+ telemetry: false,
+ });
+
+ await client.getUserInfo('token');
+
+ expect(request.isDone()).toBe(true);
+ });
+});
diff --git a/test/userinfo/fixtures/userinfo.json b/test/userinfo/fixtures/userinfo.json
new file mode 100644
index 000000000..4a7d8cd81
--- /dev/null
+++ b/test/userinfo/fixtures/userinfo.json
@@ -0,0 +1,32 @@
+[
+ {
+ "scope": "https://test-domain.auth0.com",
+ "method": "GET",
+ "path": "/userinfo",
+ "status": 200,
+ "response": {
+ "sub": "248289761001",
+ "name": "Jane Josephine Doe",
+ "given_name": "Jane",
+ "family_name": "Doe",
+ "middle_name": "Josephine",
+ "nickname": "JJ",
+ "preferred_username": "j.doe",
+ "profile": "http://exampleco.com/janedoe",
+ "picture": "http://exampleco.com/janedoe/me.jpg",
+ "website": "http://exampleco.com",
+ "email": "janedoe@exampleco.com",
+ "email_verified": true,
+ "gender": "female",
+ "birthdate": "1972-03-31",
+ "zoneinfo": "America/Los_Angeles",
+ "locale": "en-US",
+ "phone_number": "+1 (111) 222-3434",
+ "phone_number_verified": false,
+ "address": {
+ "country": "us"
+ },
+ "updated_at": "1556845729"
+ }
+ }
+]
diff --git a/test/userinfo/index.test.ts b/test/userinfo/index.test.ts
new file mode 100644
index 000000000..2d30cf459
--- /dev/null
+++ b/test/userinfo/index.test.ts
@@ -0,0 +1,48 @@
+import nock from 'nock';
+import { beforeAll, afterAll } from '@jest/globals';
+import { UserInfoClient } from '../../src/userinfo';
+
+const { back: nockBack } = nock;
+
+const opts = {
+ domain: 'test-domain.auth0.com',
+};
+
+describe('Users', () => {
+ let nockDone: () => void;
+
+ beforeAll(async () => {
+ ({ nockDone } = await nockBack('userinfo/fixtures/userinfo.json'));
+ });
+
+ afterAll(() => {
+ nockDone();
+ });
+
+ describe('#getUserInfo', () => {
+ it('should get the user info', async () => {
+ const users = new UserInfoClient(opts);
+ const accessToken = 'MY_TOKEN';
+ const { data } = await users.getUserInfo(accessToken);
+
+ expect(data).toEqual(
+ expect.objectContaining({
+ sub: '248289761001',
+ })
+ );
+ });
+
+ it('should use the provided access token', async () => {
+ const scope = nock('https://test-domain.auth0.com')
+ .get('/userinfo')
+ .matchHeader('Authorization', `Bearer MY_TOKEN`)
+ .reply(200, {});
+
+ const users = new UserInfoClient(opts);
+ const accessToken = 'MY_TOKEN';
+ await users.getUserInfo(accessToken);
+
+ expect(scope.isDone()).toBeTruthy();
+ });
+ });
+});