Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for userInfo endpoint #872

Merged
merged 7 commits into from
Jul 18, 2023
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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './management/index.js';
export * from './auth/index.js';
export * from './userinfo/index.js';
120 changes: 120 additions & 0 deletions src/userinfo/index.ts
Original file line number Diff line number Diff line change
@@ -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 <caption>
* Get the user information based on the Auth0 access token (obtained during
* login). Find more information in the
* <a href="https://auth0.com/docs/auth-api#!#get--userinfo">API Docs</a>.
* </caption>
*
* const userInfo = await auth0.users.getUserInfo(accessToken);
*/
async getUserInfo(
accessToken: string,
initOverrides?: InitOverride
): Promise<JSONApiResponse<UserInfoResponse>> {
const response = await this.request(
{
path: `/userinfo`,
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
initOverrides
);

return JSONApiResponse.fromResponse<UserInfoResponse>(response);
}
}
103 changes: 102 additions & 1 deletion test/runtime/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
32 changes: 32 additions & 0 deletions test/userinfo/fixtures/userinfo.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
48 changes: 48 additions & 0 deletions test/userinfo/index.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});