Skip to content
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
105 changes: 104 additions & 1 deletion EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2117,7 +2117,7 @@ this.auth.passkey

## MyAccount API

The MyAccount API lets authenticated users manage their own authentication methods (passkeys, TOTP, phone, email, push, recovery codes, passwords) and list available factors — all without requiring an admin token.
The MyAccount API lets authenticated users manage their own profile and authentication methods (passkeys, TOTP, phone, email, push, recovery codes, passwords) and list available factors — all without requiring an admin token.

`AuthService` exposes a `myAccount` client. All methods return Observables and require an access token with the appropriate scope for each operation. Use MRRT to obtain tokens for the MyAccount API alongside your own API audience.

Expand All @@ -2137,6 +2137,10 @@ The MyAccount API lets authenticated users manage their own authentication metho
- [Push Notification](#enroll-push-notification)
- [Recovery Code](#enroll-recovery-code)
- [Password](#enroll-password)
- [Profile](#profile)
- [Get Profile](#get-profile)
- [Select Fields](#select-fields)
- [Update Profile](#update-profile)
- [MyAccount Error Handling](#myaccount-error-handling)

### MyAccount Setup
Expand Down Expand Up @@ -2423,6 +2427,103 @@ this.auth.myAccount
.subscribe({ next: (method) => console.log('Password enrolled:', method) });
```

### Profile

> [!NOTE]
> My Account Profile API support via SDKs is currently in Early Access. To request access to this feature, contact your Auth0 representative.

Read and update the current user's profile. `getUserProfile()` needs the `read:me:profile` scope, and `updateUserProfile()` needs `update:me:profile`.

> [!NOTE]
> The profile endpoints must be enabled for your tenant. If they aren't, or the scopes weren't granted, both methods fail: with a `MyAccountApiError` with `status` `403`, or with a `MissingScopesError` if you use refresh tokens with MRRT.

#### Get Profile

```ts
this.auth.myAccount.getUserProfile().subscribe({
next: (profile) => console.log(profile.given_name, profile.email),
error: (err) => console.error(err),
});
```

The response includes `profile_policy`, which describes each field. Use it to decide which fields the user can edit:

```ts
this.auth.myAccount.getUserProfile().subscribe((profile) => {
for (const [pointer, policy] of Object.entries(profile.profile_policy ?? {})) {
const field = pointer.slice(1); // '/given_name' -> 'given_name'
const editable = policy.access === 'read_write';
// policy.reason — why the field is read-only, e.g. 'managed_by_identity_provider'
}
});
```

#### Select Fields

When you pass `fields`, `profile_policy` is only returned if you include it, and then only for the selected fields. Call `getUserProfile()` without `fields` to get the full policy.

```ts
// Only these fields, without profile_policy
this.auth.myAccount
.getUserProfile({ fields: ['given_name', 'family_name', 'picture'] })
.subscribe((basic) => console.log(basic));

// These fields, with profile_policy for just these fields (for an edit form)
this.auth.myAccount
.getUserProfile({ fields: ['given_name', 'family_name', 'profile_policy'] })
.subscribe((editable) => console.log(editable));

// Everything except profile_policy
this.auth.myAccount
.getUserProfile({ fields: ['profile_policy'], includeFields: false })
.subscribe((withoutPolicy) => console.log(withoutPolicy));
```

#### Update Profile

Only the fields you send are changed. The response is the full updated profile. You can update `given_name`, `family_name`, `name`, `nickname` and `picture`. `email`, `phone_number` and `username` can't be changed here, and `null` isn't accepted for any field.

```ts
this.auth.myAccount
.updateUserProfile({ given_name: 'Alice', nickname: 'wonderland_alice' })
.subscribe({
next: (updated) => console.log(updated),
error: (err) => console.error(err),
});
```

`updateUserProfile()` doesn't update `user$` or `idTokenClaims$`, which come from the ID token and still have the old values. To show the new values, use the profile that `updateUserProfile()` returns, or call `getAccessTokenSilently({ cacheMode: 'off' })` afterwards to get a new ID token. With refresh tokens enabled, as in [MyAccount Setup](#myaccount-setup), this uses the refresh token, not an iframe:

```ts
import { switchMap } from 'rxjs';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

this.auth.myAccount
.updateUserProfile({ given_name: 'Alice' })
.pipe(
switchMap(() => this.auth.getAccessTokenSilently({ cacheMode: 'off' }))
)
.subscribe({
next: () => {
// user$ and idTokenClaims$ now emit the updated profile
},
error: (err) => console.error(err),
});
```

If a field is read-only for this user (for example, `given_name` for a social login user), the request fails with a `MyAccountApiError` and nothing is saved. `err.validation_errors` says which field was rejected:

```ts
import { MyAccountApiError } from '@auth0/auth0-angular';

this.auth.myAccount.updateUserProfile({ given_name: 'Alice' }).subscribe({
error: (err) => {
if (err instanceof MyAccountApiError) {
err.validation_errors?.forEach((e) => console.error(e.pointer, e.detail));
}
},
});
```

### MyAccount Error Handling

All MyAccount API errors are thrown as `MyAccountApiError` with [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) fields: `status`, `title`, `detail`, and an optional `validation_errors` array.
Expand Down Expand Up @@ -2456,6 +2557,8 @@ this.auth.myAccount
// update:me:authentication_methods — updateAuthenticationMethod
// delete:me:authentication_methods — deleteAuthenticationMethod
// read:me:factors — getFactors
// read:me:profile — getUserProfile
// update:me:profile — updateUserProfile
```

## Enterprise Connect
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"@angular/core": "^20.3.34",
"@angular/platform-browser": "^20.3.34",
"@angular/router": "^20.3.34",
"@auth0/auth0-spa-js": "^2.29.0",
"@auth0/auth0-spa-js": "^2.30.0",
"rxjs": "^6.6.7",
"tslib": "^2.8.1",
"zone.js": "~0.15.1"
Expand Down
2 changes: 1 addition & 1 deletion projects/auth0-angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"dependencies": {
"tslib": "^2.0.0",
"@auth0/auth0-spa-js": "^2.29.0"
"@auth0/auth0-spa-js": "^2.30.0"
},
"schematics": "./schematics/collection.json"
}
168 changes: 168 additions & 0 deletions projects/auth0-angular/src/lib/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PasskeyError,
PasskeyGetTokenError,
PasskeyVerificationRequiredError,
MyAccountApiError,
} from '@auth0/auth0-spa-js';
import { AbstractNavigator } from './abstract-navigator';
import { Observable } from 'rxjs';
Expand Down Expand Up @@ -221,6 +222,22 @@ describe('AuthService', () => {
vi.spyOn(auth0Client.myAccount, 'enrollmentVerify').mockResolvedValue({
id: '__method_id__',
} as any);
vi.spyOn(auth0Client.myAccount, 'getUserProfile').mockResolvedValue({
user_id: 'auth0|123',
given_name: 'Alice',
email: 'alice@example.com',
email_verified: true,
profile_policy: {
'/given_name': { access: 'read_write', source: 'user' },
},
});
vi.spyOn(auth0Client.myAccount, 'updateUserProfile').mockResolvedValue({
given_name: 'Alice',
nickname: 'wonderland_alice',
profile_policy: {
'/given_name': { access: 'read_write', source: 'user' },
},
});

window.history.replaceState(null, '', '');

Expand Down Expand Up @@ -2792,5 +2809,156 @@ describe('AuthService', () => {
).rejects.toBe(errorObj);
});
});

describe('getUserProfile', () => {
it('should call the underlying SDK with no options', async () => {
const service = createService();
await firstValueFrom(service.myAccount.getUserProfile());
expect(auth0Client.myAccount.getUserProfile).toHaveBeenCalledWith(
undefined
);
});

it('should call the underlying SDK with fields', async () => {
const service = createService();
const options = { fields: ['given_name', 'family_name'] };
await firstValueFrom(service.myAccount.getUserProfile(options));
expect(auth0Client.myAccount.getUserProfile).toHaveBeenCalledWith(
options
);
});

it('should call the underlying SDK with includeFields false', async () => {
const service = createService();
const options = { fields: ['profile_policy'], includeFields: false };
await firstValueFrom(service.myAccount.getUserProfile(options));
expect(auth0Client.myAccount.getUserProfile).toHaveBeenCalledWith(
options
);
});

it('should return the profile', async () => {
const service = createService();
const result = await firstValueFrom(service.myAccount.getUserProfile());
expect(result.user_id).toBe('auth0|123');
expect(result.given_name).toBe('Alice');
expect(result.email_verified).toBe(true);
expect(result.profile_policy).toEqual({
'/given_name': { access: 'read_write', source: 'user' },
});
});

it('should bubble MyAccountApiError when the feature is not enabled', async () => {
const errorObj = new MyAccountApiError({
type: 'https://auth0.com/api-errors/A0E-403-0003',
status: 403,
title: 'Not Entitled',
detail: 'This feature is currently not enabled',
});
(
auth0Client.myAccount.getUserProfile as unknown as MockInstance
).mockRejectedValue(errorObj);
const service = createService();
await expect(
firstValueFrom(service.myAccount.getUserProfile())
).rejects.toBe(errorObj);
});
});

describe('updateUserProfile', () => {
it('should call the underlying SDK with data', async () => {
const service = createService();
const data = { given_name: 'Alice', nickname: 'wonderland_alice' };
await firstValueFrom(service.myAccount.updateUserProfile(data));
expect(auth0Client.myAccount.updateUserProfile).toHaveBeenCalledWith(
data
);
});

it('should pass an empty object through unchanged', async () => {
const service = createService();
await firstValueFrom(service.myAccount.updateUserProfile({}));
expect(auth0Client.myAccount.updateUserProfile).toHaveBeenCalledWith(
{}
);
});

it('should return the updated profile', async () => {
const service = createService();
const result = await firstValueFrom(
service.myAccount.updateUserProfile({ nickname: 'wonderland_alice' })
);
expect(result.nickname).toBe('wonderland_alice');
});

it('should bubble MyAccountApiError with validation_errors', async () => {
const errorObj = new MyAccountApiError({
type: 'A0E-400-0008',
status: 400,
title: 'read_only_field',
detail: 'Field is read-only',
validation_errors: [
{
pointer: '/given_name',
detail: 'Field is managed by the identity provider',
source: 'managed_by_identity_provider',
},
],
});
(
auth0Client.myAccount.updateUserProfile as unknown as MockInstance
).mockRejectedValue(errorObj);
const service = createService();
await expect(
firstValueFrom(
service.myAccount.updateUserProfile({ given_name: 'Alice' })
)
).rejects.toBe(errorObj);
});

it('should not update user$ until a new ID token is fetched', async () => {
// user$ comes from the cached ID token, which updateUserProfile() does not
// change. Callers follow up with getAccessTokenSilently({ cacheMode: 'off' }).
const user = { sub: 'auth0|123', given_name: 'Old' };
const updatedUser = { sub: 'auth0|123', given_name: 'Alice' };
(
auth0Client.isAuthenticated as unknown as MockInstance
).mockResolvedValue(true);
(auth0Client.getUser as unknown as MockInstance).mockResolvedValue(
user
);

const service = createService();
const users: unknown[] = [];
await new Promise<void>((resolve) =>
service.user$.subscribe((value) => {
users.push(value);
resolve();
})
);

// Any re-read of user$ from here on would emit updatedUser
(auth0Client.getUser as unknown as MockInstance).mockResolvedValue(
updatedUser
);
await firstValueFrom(
service.myAccount
.updateUserProfile({ given_name: 'Alice' })
.pipe(delay(0))
);
expect(users).toEqual([user]);

(
auth0Client.getTokenSilently as unknown as MockInstance
).mockResolvedValue('__new_access_token__');
await firstValueFrom(
service.getAccessTokenSilently({ cacheMode: 'off' }).pipe(delay(0))
);
expect(auth0Client.getTokenSilently).toHaveBeenCalledWith({
cacheMode: 'off',
});
expect(users).toEqual([user, updatedUser]);
});
});
});
});
12 changes: 10 additions & 2 deletions projects/auth0-angular/src/lib/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import type {
EnrollmentChallengeOptions,
EnrollmentChallengeResponse,
EnrollmentVerifyOptions,
GetUserProfileOptions,
UpdateUserProfileRequest,
} from '@auth0/auth0-spa-js';

import {
Expand Down Expand Up @@ -621,8 +623,10 @@ export class AuthService<TAppState extends AppState = AppState>
* Provides MyAccount API operations as Observables.
*
* Allows the authenticated user to list, enroll, update, and delete their
* authentication methods. Requires an access token with the appropriate
* `read/create/update/delete:me:authentication-methods` scopes.
* authentication methods, and to read and update their profile. Requires an
* access token with the appropriate
* `read/create/update/delete:me:authentication_methods` scopes, and
* `read:me:profile` / `update:me:profile` for the profile methods.
*/
readonly myAccount: ObservableMyAccountApiClient = {
getFactors: () => from(this.auth0Client.myAccount.getFactors()),
Expand All @@ -640,6 +644,10 @@ export class AuthService<TAppState extends AppState = AppState>
from(this.auth0Client.myAccount.enrollmentChallenge(options)),
enrollmentVerify: (options: EnrollmentVerifyOptions) =>
from(this.auth0Client.myAccount.enrollmentVerify(options)),
getUserProfile: (options?: GetUserProfileOptions) =>
from(this.auth0Client.myAccount.getUserProfile(options)),
updateUserProfile: (data: UpdateUserProfileRequest) =>
from(this.auth0Client.myAccount.updateUserProfile(data)),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};

private shouldHandleCallback(): Observable<boolean> {
Expand Down
Loading
Loading