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(); + }); + }); +});