Skip to content

Commit 49ba3df

Browse files
feat(userinfo): add getUserInfo() to auth0-auth-js and auth0-server-js (G8)
Adds OIDC /userinfo support to close the last gap (G8) in the node-auth0 authentication-separation parity set. auth0-auth-js (stateless core): - AuthClient.getUserInfo(options): live /userinfo fetch via openid-client fetchUserInfo; optional expectedSubject (defaults to skipSubjectCheck). - New UserInfoError (extends ApiError), GetUserInfoOptions, and an auth0-owned UserInfoResponse interface (only `sub` required, catch-all index signature) for a stable public contract independent of openid-client. auth0-server-js (session layer): - ServerClient.getUserInfo(storeOptions?): live fetch using the session's access token (auto-refresh via getAccessToken). Throws MissingSessionError on no session, missing user sub, or resolver-mode domain mismatch; resolves the per-domain AuthClient in resolver mode. Always passes the session `sub` as expectedSubject for an OIDC subject-consistency check. Re-exports UserInfoError. Tests: 12 auth-js + 11 server-js (MSW HTTP-layer). Docs: EXAMPLES.md in both packages. Additive, minor bump; no breaking changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 60d0e42 commit 49ba3df

9 files changed

Lines changed: 1021 additions & 20 deletions

File tree

packages/auth0-auth-js/EXAMPLES.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
- [Retrieving a Token for a Connection](#retrieving-a-token-for-a-connection)
2929
- [Building the Logout URL](#building-the-logout-url)
3030
- [Verifying the Logout Token](#verifying-the-logout-token)
31+
- [Retrieving User Information](#retrieving-user-information)
3132
- [Using Passwordless Authentication](#using-passwordless-authentication)
3233
- [Classic Email/SMS Passwordless (/passwordless/start)](#classic-emailsms-passwordless-passwordlessstart)
3334
- [Sending an Email Code](#sending-an-email-code)
@@ -669,6 +670,70 @@ const { sid, sub } = await authClient.verifyLogoutToken({ logoutToken });
669670
670671
When the verification is successful, the `sid` and `sub` claims will be returned. If not, an error will be thrown.
671672
673+
## Retrieving User Information
674+
675+
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.
676+
677+
```ts
678+
import { AuthClient } from '@auth0/auth0-auth-js';
679+
680+
const authClient = new AuthClient({
681+
domain: '<AUTH0_DOMAIN>',
682+
clientId: '<AUTH0_CLIENT_ID>',
683+
clientSecret: '<AUTH0_CLIENT_SECRET>',
684+
});
685+
686+
// Retrieve user information with an access token
687+
const userInfo = await authClient.getUserInfo({
688+
accessToken: '<access_token>',
689+
});
690+
691+
console.log(userInfo.sub);
692+
console.log(userInfo.email);
693+
console.log(userInfo.name);
694+
```
695+
696+
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.
697+
698+
### Optional Subject Validation
699+
700+
You can optionally validate that the returned `sub` claim matches an expected value. This is useful for security checks:
701+
702+
```ts
703+
const userInfo = await authClient.getUserInfo({
704+
accessToken: myAccessToken,
705+
expectedSubject: 'auth0|user123',
706+
});
707+
708+
// If the returned sub doesn't match expectedSubject, getUserInfo() throws UserInfoError
709+
```
710+
711+
If the `expectedSubject` parameter is not provided, subject validation is skipped.
712+
713+
### Error Handling
714+
715+
The `getUserInfo()` method throws `UserInfoError` when the request fails. Common error scenarios include:
716+
717+
- **401 Unauthorized**: The access token is expired, revoked, or invalid.
718+
- **403 Forbidden**: The access token is valid but lacks the required scope.
719+
- **Subject Mismatch**: The returned `sub` claim does not match the `expectedSubject` (if provided).
720+
721+
```ts
722+
import { AuthClient, UserInfoError } from '@auth0/auth0-auth-js';
723+
724+
try {
725+
const userInfo = await authClient.getUserInfo({
726+
accessToken: myAccessToken,
727+
});
728+
} catch (error) {
729+
if (error instanceof UserInfoError) {
730+
console.error('Failed to retrieve user info:', error.message);
731+
console.error('Error code:', error.code); // 'user_info_error'
732+
console.error('OAuth error:', error.cause?.error); // e.g., 'unauthorized'
733+
}
734+
}
735+
```
736+
672737
## Using Passwordless Authentication
673738
674739
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:

packages/auth0-auth-js/src/auth-client.spec.ts

Lines changed: 239 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { expect, test, afterAll, beforeAll, beforeEach, vi, afterEach, describe
22
import { setupServer } from 'msw/node';
33
import { http, HttpResponse } from 'msw';
44
import { AuthClient } from './auth-client.js';
5-
import { NotSupportedError, isMfaRequiredError, TokenByPasswordError, OrganizationValidationError } from './errors.js';
5+
import { NotSupportedError, isMfaRequiredError, TokenByPasswordError, OrganizationValidationError, UserInfoError } from './errors.js';
66
import { PasskeyGetTokenError } from './passkey/errors.js';
77
import { PasswordlessVerifyError } from './passwordless/errors.js';
88
import { ExchangeProfileOptions } from './types.js';
@@ -25,6 +25,7 @@ const buildOpenIdConfiguration = (customDomain: string) => ({
2525
token_endpoint: `https://${customDomain}/custom/token`,
2626
end_session_endpoint: `https://${customDomain}/logout`,
2727
pushed_authorization_request_endpoint: `https://${customDomain}/pushed-authorize`,
28+
userinfo_endpoint: `https://${customDomain}/userinfo`,
2829
jwks_uri: `https://${customDomain}/.well-known/jwks.json`,
2930
mtls_endpoint_aliases: {
3031
token_endpoint: `https://mtls.${customDomain}/oauth/token`,
@@ -215,6 +216,66 @@ const restHandlers = [
215216
{ status: 201 }
216217
);
217218
}),
219+
220+
http.get(`https://${domain}/userinfo`, ({ request }) => {
221+
const authHeader = request.headers.get('authorization');
222+
223+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
224+
return HttpResponse.json(
225+
{ error: 'unauthorized', error_description: 'Missing or invalid authorization header' },
226+
{ status: 401 }
227+
);
228+
}
229+
230+
const token = authHeader.replace('Bearer ', '');
231+
232+
// Special test tokens map to responses
233+
if (token === '<userinfo_401>') {
234+
return HttpResponse.json(
235+
{ error: 'unauthorized', error_description: 'The access token expired' },
236+
{ status: 401 }
237+
);
238+
}
239+
240+
if (token === '<userinfo_403>') {
241+
return HttpResponse.json(
242+
{ error: 'forbidden', error_description: 'Insufficient scope for /userinfo endpoint' },
243+
{ status: 403 }
244+
);
245+
}
246+
247+
if (token === '<userinfo_subject_mismatch>') {
248+
return HttpResponse.json({
249+
sub: 'user_wrong',
250+
name: 'Wrong User',
251+
email: 'wrong@example.com',
252+
});
253+
}
254+
255+
// Default: return full OIDC claims + custom claim
256+
return HttpResponse.json({
257+
sub: 'user_123',
258+
name: 'Jane Doe',
259+
email: 'jane@example.com',
260+
email_verified: true,
261+
updated_at: 1625000000,
262+
picture: 'https://example.com/picture.jpg',
263+
nickname: 'jane',
264+
given_name: 'Jane',
265+
family_name: 'Doe',
266+
phone_number: '+1-555-0100',
267+
phone_number_verified: false,
268+
address: {
269+
formatted: '123 Main St, Springfield, USA',
270+
street_address: '123 Main St',
271+
locality: 'Springfield',
272+
region: 'IL',
273+
postal_code: '62701',
274+
country: 'USA',
275+
},
276+
custom_claim: 'custom_value',
277+
});
278+
}),
218279
];
219280

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

237298
afterEach(() => {
238-
mockOpenIdConfiguration = {
239-
issuer: `https://${domain}/`,
240-
authorization_endpoint: `https://${domain}/authorize`,
241-
backchannel_authentication_endpoint: `https://${domain}/custom-authorize`,
242-
token_endpoint: `https://${domain}/custom/token`,
243-
end_session_endpoint: `https://${domain}/logout`,
244-
pushed_authorization_request_endpoint: `https://${domain}/pushed-authorize`,
245-
jwks_uri: `https://${domain}/.well-known/jwks.json`,
246-
mtls_endpoint_aliases: {
247-
token_endpoint: `https://mtls.${domain}/oauth/token`,
248-
userinfo_endpoint: `https://mtls.${domain}/userinfo`,
249-
revocation_endpoint: `https://mtls.${domain}/oauth/revoke`,
250-
pushed_authorization_request_endpoint: `https://mtls.${domain}/oauth/par`,
251-
},
252-
};
299+
mockOpenIdConfiguration = buildOpenIdConfiguration(domain);
253300
server.resetHandlers();
254301
});
255302

@@ -4082,3 +4129,179 @@ describe('revokeToken', () => {
40824129
expect(capturedClientSecret).toBe('<client_secret>');
40834130
});
40844131
});
4132+
4133+
describe('getUserInfo', () => {
4134+
const makeClient = () =>
4135+
new AuthClient({
4136+
domain,
4137+
clientId: '<client_id>',
4138+
clientSecret: '<client_secret>',
4139+
});
4140+
4141+
afterEach(() => {
4142+
// After outer afterEach resets handlers, restore restHandlers for next test
4143+
server.use(...restHandlers);
4144+
});
4145+
4146+
test('A1 - Success: valid token, full claims', async () => {
4147+
const client = makeClient();
4148+
4149+
const result = await client.getUserInfo({ accessToken });
4150+
4151+
expect(result.sub).toBe('user_123');
4152+
expect(result.email).toBe('jane@example.com');
4153+
expect(result.name).toBe('Jane Doe');
4154+
expect(result.custom_claim).toBe('custom_value');
4155+
});
4156+
4157+
test('A2 - Success: minimal claims (only required sub)', async () => {
4158+
const client = makeClient();
4159+
4160+
const result = await client.getUserInfo({ accessToken });
4161+
4162+
// The default handler returns all claims, but we verify sub is always present
4163+
expect(result.sub).toBe('user_123');
4164+
expect(typeof result.sub).toBe('string');
4165+
});
4166+
4167+
test('A3 - Success: with expectedSubject (match)', async () => {
4168+
const client = makeClient();
4169+
4170+
const result = await client.getUserInfo({
4171+
accessToken,
4172+
expectedSubject: 'user_123',
4173+
});
4174+
4175+
expect(result.sub).toBe('user_123');
4176+
});
4177+
4178+
test('A4 - Success: skip subject check (default, no expectedSubject)', async () => {
4179+
const client = makeClient();
4180+
4181+
// When no expectedSubject is provided, openid-client.skipSubjectCheck is used
4182+
const result = await client.getUserInfo({ accessToken });
4183+
4184+
expect(result.sub).toBe('user_123');
4185+
expect(result.email).toBe('jane@example.com');
4186+
});
4187+
4188+
test('A5 - HTTP 401 Unauthorized', async () => {
4189+
const client = makeClient();
4190+
const token401 = '<userinfo_401>';
4191+
4192+
const err = await client.getUserInfo({ accessToken: token401 }).catch((e) => e);
4193+
4194+
expect(err).toBeInstanceOf(UserInfoError);
4195+
expect(err.name).toBe('UserInfoError');
4196+
expect(err.code).toBe('user_info_error');
4197+
expect(err.message).toContain('There was an error');
4198+
expect(err.message).not.toContain(token401);
4199+
});
4200+
4201+
test('A6 - HTTP 403 Forbidden', async () => {
4202+
const client = makeClient();
4203+
const token403 = '<userinfo_403>';
4204+
4205+
const err = await client.getUserInfo({ accessToken: token403 }).catch((e) => e);
4206+
4207+
expect(err).toBeInstanceOf(Error);
4208+
expect(err.name).toBe('UserInfoError');
4209+
expect(err.code).toBe('user_info_error');
4210+
});
4211+
4212+
test('A7 - Network error (fetch fails)', async () => {
4213+
// Override with a handler that throws (higher priority than existing)
4214+
server.use(
4215+
http.get(`https://${domain}/userinfo`, () => {
4216+
throw new Error('Network error');
4217+
})
4218+
);
4219+
const client = makeClient();
4220+
4221+
const err = await client.getUserInfo({ accessToken }).catch((e) => e);
4222+
4223+
expect(err).toBeInstanceOf(Error);
4224+
expect(err.name).toBe('UserInfoError');
4225+
expect(err.code).toBe('user_info_error');
4226+
});
4227+
4228+
test('A8 - Subject mismatch (expectedSubject mismatch)', async () => {
4229+
const client = makeClient();
4230+
4231+
const err = await client
4232+
.getUserInfo({
4233+
accessToken: '<userinfo_subject_mismatch>',
4234+
expectedSubject: 'user_correct',
4235+
})
4236+
.catch((e) => e);
4237+
4238+
expect(err).toBeInstanceOf(Error);
4239+
expect(err.name).toBe('UserInfoError');
4240+
expect(err.code).toBe('user_info_error');
4241+
});
4242+
4243+
test('A9 - Missing userinfo_endpoint in discovery', async () => {
4244+
// Create new client with different domain to test missing endpoint scenario
4245+
const noDomain = 'no-userinfo.auth0.local';
4246+
server.use(
4247+
http.get(`https://${noDomain}/.well-known/openid-configuration`, () => {
4248+
return HttpResponse.json({
4249+
issuer: `https://${noDomain}/`,
4250+
authorization_endpoint: `https://${noDomain}/authorize`,
4251+
token_endpoint: `https://${noDomain}/custom/token`,
4252+
jwks_uri: `https://${noDomain}/.well-known/jwks.json`,
4253+
// userinfo_endpoint OMITTED
4254+
});
4255+
})
4256+
);
4257+
const client = new AuthClient({
4258+
domain: noDomain,
4259+
clientId: '<client_id>',
4260+
clientSecret: '<client_secret>',
4261+
});
4262+
4263+
const err = await client.getUserInfo({ accessToken }).catch((e) => e);
4264+
4265+
expect(err).toBeInstanceOf(Error);
4266+
expect(err.name).toBe('UserInfoError');
4267+
expect(err.code).toBe('user_info_error');
4268+
});
4269+
4270+
test('A10 - Calls discovery for metadata', async () => {
4271+
const client = makeClient();
4272+
4273+
// Just verify that the method succeeds (discovery is called implicitly)
4274+
const result = await client.getUserInfo({ accessToken });
4275+
4276+
expect(result.sub).toBe('user_123');
4277+
});
4278+
4279+
test('A11 - Uses Authorization Bearer header', async () => {
4280+
let capturedAuthHeader: string | null = null;
4281+
server.use(
4282+
http.get(`https://${domain}/userinfo`, ({ request }) => {
4283+
capturedAuthHeader = request.headers.get('authorization');
4284+
return HttpResponse.json({ sub: 'user_123' });
4285+
})
4286+
);
4287+
const client = makeClient();
4288+
4289+
await client.getUserInfo({ accessToken });
4290+
4291+
expect(capturedAuthHeader).toBe(`Bearer ${accessToken}`);
4292+
});
4293+
4294+
test('A12 - Error message does not leak token', async () => {
4295+
const client = makeClient();
4296+
// Use a 401 token to trigger an error path
4297+
const sensitiveToken = '<userinfo_401>';
4298+
4299+
const err = await client.getUserInfo({ accessToken: sensitiveToken }).catch((e) => e);
4300+
4301+
expect(err).toBeInstanceOf(Error);
4302+
expect(err.message).not.toContain(sensitiveToken);
4303+
if (err.cause) {
4304+
expect(JSON.stringify(err.cause)).not.toContain(sensitiveToken);
4305+
}
4306+
});
4307+
});

0 commit comments

Comments
 (0)