Skip to content
Draft
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
65 changes: 65 additions & 0 deletions packages/auth0-auth-js/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- [Retrieving a Token for a Connection](#retrieving-a-token-for-a-connection)
- [Building the Logout URL](#building-the-logout-url)
- [Verifying the Logout Token](#verifying-the-logout-token)
- [Retrieving User Information](#retrieving-user-information)
- [Using Passwordless Authentication](#using-passwordless-authentication)
- [Classic Email/SMS Passwordless (/passwordless/start)](#classic-emailsms-passwordless-passwordlessstart)
- [Sending an Email Code](#sending-an-email-code)
Expand Down Expand Up @@ -669,6 +670,70 @@ const { sid, sub } = await authClient.verifyLogoutToken({ logoutToken });

When the verification is successful, the `sid` and `sub` claims will be returned. If not, an error will be thrown.

## Retrieving User Information

The SDK provides a method to retrieve user profile information from the OIDC `/userinfo` endpoint. This is useful when you need to fetch fresh user claims using an access token.

```ts
import { AuthClient } from '@auth0/auth0-auth-js';

const authClient = new AuthClient({
domain: '<AUTH0_DOMAIN>',
clientId: '<AUTH0_CLIENT_ID>',
clientSecret: '<AUTH0_CLIENT_SECRET>',
});

// Retrieve user information with an access token
const userInfo = await authClient.getUserInfo({
accessToken: '<access_token>',
});

console.log(userInfo.sub);
console.log(userInfo.email);
console.log(userInfo.name);
```

The returned `UserInfoResponse` object contains OIDC standard claims like `sub`, `email`, `name`, and other profile information. The exact claims returned depend on the scopes requested during authentication and the user's profile data.

### Optional Subject Validation

You can optionally validate that the returned `sub` claim matches an expected value. This is useful for security checks:

```ts
const userInfo = await authClient.getUserInfo({
accessToken: myAccessToken,
expectedSubject: 'auth0|user123',
});

// If the returned sub doesn't match expectedSubject, getUserInfo() throws UserInfoError
```

If the `expectedSubject` parameter is not provided, subject validation is skipped.

### Error Handling

The `getUserInfo()` method throws `UserInfoError` when the request fails. Common error scenarios include:

- **401 Unauthorized**: The access token is expired, revoked, or invalid.
- **403 Forbidden**: The access token is valid but lacks the required scope.
- **Subject Mismatch**: The returned `sub` claim does not match the `expectedSubject` (if provided).

```ts
import { AuthClient, UserInfoError } from '@auth0/auth0-auth-js';

try {
const userInfo = await authClient.getUserInfo({
accessToken: myAccessToken,
});
} catch (error) {
if (error instanceof UserInfoError) {
console.error('Failed to retrieve user info:', error.message);
console.error('Error code:', error.code); // 'user_info_error'
console.error('OAuth error:', error.cause?.error); // e.g., 'unauthorized'
}
}
```

## Using Passwordless Authentication

Passwordless lets users authenticate with a one-time code (or magic link) delivered by email or SMS, rather than a password. The SDK supports two passwordless approaches:
Expand Down
255 changes: 239 additions & 16 deletions packages/auth0-auth-js/src/auth-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, test, afterAll, beforeAll, beforeEach, vi, afterEach, describe
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { AuthClient } from './auth-client.js';
import { NotSupportedError, isMfaRequiredError, TokenByPasswordError, OrganizationValidationError } from './errors.js';
import { NotSupportedError, isMfaRequiredError, TokenByPasswordError, OrganizationValidationError, UserInfoError } from './errors.js';
import { PasskeyGetTokenError } from './passkey/errors.js';
import { PasswordlessVerifyError } from './passwordless/errors.js';
import { ExchangeProfileOptions } from './types.js';
Expand All @@ -25,6 +25,7 @@ const buildOpenIdConfiguration = (customDomain: string) => ({
token_endpoint: `https://${customDomain}/custom/token`,
end_session_endpoint: `https://${customDomain}/logout`,
pushed_authorization_request_endpoint: `https://${customDomain}/pushed-authorize`,
userinfo_endpoint: `https://${customDomain}/userinfo`,
jwks_uri: `https://${customDomain}/.well-known/jwks.json`,
mtls_endpoint_aliases: {
token_endpoint: `https://mtls.${customDomain}/oauth/token`,
Expand Down Expand Up @@ -215,6 +216,66 @@ const restHandlers = [
{ status: 201 }
);
}),

http.get(`https://${domain}/userinfo`, ({ request }) => {
const authHeader = request.headers.get('authorization');

if (!authHeader || !authHeader.startsWith('Bearer ')) {
return HttpResponse.json(
{ error: 'unauthorized', error_description: 'Missing or invalid authorization header' },
{ status: 401 }
);
}

const token = authHeader.replace('Bearer ', '');

// Special test tokens map to responses
if (token === '<userinfo_401>') {
return HttpResponse.json(
{ error: 'unauthorized', error_description: 'The access token expired' },
{ status: 401 }
);
}

if (token === '<userinfo_403>') {
return HttpResponse.json(
{ error: 'forbidden', error_description: 'Insufficient scope for /userinfo endpoint' },
{ status: 403 }
);
}

if (token === '<userinfo_subject_mismatch>') {
return HttpResponse.json({
sub: 'user_wrong',
name: 'Wrong User',
email: 'wrong@example.com',
});
}

// Default: return full OIDC claims + custom claim
return HttpResponse.json({
sub: 'user_123',
name: 'Jane Doe',
email: 'jane@example.com',
email_verified: true,
updated_at: 1625000000,
picture: 'https://example.com/picture.jpg',
nickname: 'jane',
given_name: 'Jane',
family_name: 'Doe',
phone_number: '+1-555-0100',
phone_number_verified: false,
address: {
formatted: '123 Main St, Springfield, USA',
street_address: '123 Main St',
locality: 'Springfield',
region: 'IL',
postal_code: '62701',
country: 'USA',
},
custom_claim: 'custom_value',
});
}),
];

const server = setupServer(...restHandlers);
Expand All @@ -235,21 +296,7 @@ beforeEach(async () => {
});

afterEach(() => {
mockOpenIdConfiguration = {
issuer: `https://${domain}/`,
authorization_endpoint: `https://${domain}/authorize`,
backchannel_authentication_endpoint: `https://${domain}/custom-authorize`,
token_endpoint: `https://${domain}/custom/token`,
end_session_endpoint: `https://${domain}/logout`,
pushed_authorization_request_endpoint: `https://${domain}/pushed-authorize`,
jwks_uri: `https://${domain}/.well-known/jwks.json`,
mtls_endpoint_aliases: {
token_endpoint: `https://mtls.${domain}/oauth/token`,
userinfo_endpoint: `https://mtls.${domain}/userinfo`,
revocation_endpoint: `https://mtls.${domain}/oauth/revoke`,
pushed_authorization_request_endpoint: `https://mtls.${domain}/oauth/par`,
},
};
mockOpenIdConfiguration = buildOpenIdConfiguration(domain);
server.resetHandlers();
});

Expand Down Expand Up @@ -4082,3 +4129,179 @@ describe('revokeToken', () => {
expect(capturedClientSecret).toBe('<client_secret>');
});
});

describe('getUserInfo', () => {
const makeClient = () =>
new AuthClient({
domain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
});

afterEach(() => {
// After outer afterEach resets handlers, restore restHandlers for next test
server.use(...restHandlers);
});

test('A1 - Success: valid token, full claims', async () => {
const client = makeClient();

const result = await client.getUserInfo({ accessToken });

expect(result.sub).toBe('user_123');
expect(result.email).toBe('jane@example.com');
expect(result.name).toBe('Jane Doe');
expect(result.custom_claim).toBe('custom_value');
});

test('A2 - Success: minimal claims (only required sub)', async () => {
const client = makeClient();

const result = await client.getUserInfo({ accessToken });

// The default handler returns all claims, but we verify sub is always present
expect(result.sub).toBe('user_123');
expect(typeof result.sub).toBe('string');
});

test('A3 - Success: with expectedSubject (match)', async () => {
const client = makeClient();

const result = await client.getUserInfo({
accessToken,
expectedSubject: 'user_123',
});

expect(result.sub).toBe('user_123');
});

test('A4 - Success: skip subject check (default, no expectedSubject)', async () => {
const client = makeClient();

// When no expectedSubject is provided, openid-client.skipSubjectCheck is used
const result = await client.getUserInfo({ accessToken });

expect(result.sub).toBe('user_123');
expect(result.email).toBe('jane@example.com');
});

test('A5 - HTTP 401 Unauthorized', async () => {
const client = makeClient();
const token401 = '<userinfo_401>';

const err = await client.getUserInfo({ accessToken: token401 }).catch((e) => e);

expect(err).toBeInstanceOf(UserInfoError);
expect(err.name).toBe('UserInfoError');
expect(err.code).toBe('user_info_error');
expect(err.message).toContain('There was an error');
expect(err.message).not.toContain(token401);
});

test('A6 - HTTP 403 Forbidden', async () => {
const client = makeClient();
const token403 = '<userinfo_403>';

const err = await client.getUserInfo({ accessToken: token403 }).catch((e) => e);

expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('UserInfoError');
expect(err.code).toBe('user_info_error');
});

test('A7 - Network error (fetch fails)', async () => {
// Override with a handler that throws (higher priority than existing)
server.use(
http.get(`https://${domain}/userinfo`, () => {
throw new Error('Network error');
})
);
const client = makeClient();

const err = await client.getUserInfo({ accessToken }).catch((e) => e);

expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('UserInfoError');
expect(err.code).toBe('user_info_error');
});

test('A8 - Subject mismatch (expectedSubject mismatch)', async () => {
const client = makeClient();

const err = await client
.getUserInfo({
accessToken: '<userinfo_subject_mismatch>',
expectedSubject: 'user_correct',
})
.catch((e) => e);

expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('UserInfoError');
expect(err.code).toBe('user_info_error');
});

test('A9 - Missing userinfo_endpoint in discovery', async () => {
// Create new client with different domain to test missing endpoint scenario
const noDomain = 'no-userinfo.auth0.local';
server.use(
http.get(`https://${noDomain}/.well-known/openid-configuration`, () => {
return HttpResponse.json({
issuer: `https://${noDomain}/`,
authorization_endpoint: `https://${noDomain}/authorize`,
token_endpoint: `https://${noDomain}/custom/token`,
jwks_uri: `https://${noDomain}/.well-known/jwks.json`,
// userinfo_endpoint OMITTED
});
})
);
const client = new AuthClient({
domain: noDomain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
});

const err = await client.getUserInfo({ accessToken }).catch((e) => e);

expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('UserInfoError');
expect(err.code).toBe('user_info_error');
});

test('A10 - Calls discovery for metadata', async () => {
const client = makeClient();

// Just verify that the method succeeds (discovery is called implicitly)
const result = await client.getUserInfo({ accessToken });

expect(result.sub).toBe('user_123');
});

test('A11 - Uses Authorization Bearer header', async () => {
let capturedAuthHeader: string | null = null;
server.use(
http.get(`https://${domain}/userinfo`, ({ request }) => {
capturedAuthHeader = request.headers.get('authorization');
return HttpResponse.json({ sub: 'user_123' });
})
);
const client = makeClient();

await client.getUserInfo({ accessToken });

expect(capturedAuthHeader).toBe(`Bearer ${accessToken}`);
});

test('A12 - Error message does not leak token', async () => {
const client = makeClient();
// Use a 401 token to trigger an error path
const sensitiveToken = '<userinfo_401>';

const err = await client.getUserInfo({ accessToken: sensitiveToken }).catch((e) => e);

expect(err).toBeInstanceOf(Error);
expect(err.message).not.toContain(sensitiveToken);
if (err.cause) {
expect(JSON.stringify(err.cause)).not.toContain(sensitiveToken);
}
});
});
Loading
Loading