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
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
@page "/dashboard/identity"
@using CodeBeam.UltimateAuth.Core.Contracts
@using CodeBeam.UltimateAuth.IdentityLab.Services
@using CodeBeam.UltimateAuth.Users.Contracts
@using CodeBeam.UltimateAuth.Server.Options
@using Microsoft.Extensions.Options
@layout DashboardLayout
@inject IOptions<UAuthServerOptions> ServerOptions
@inject SandboxAccountDeletionService SandboxAccountDeletionService
@inject LabDashboardService LabDashboardService
@inject IUAuthClient UAuthClient
Expand Down Expand Up @@ -55,43 +55,26 @@
@if (_loading)
{
<MudStack Spacing="3" Class="py-4">
<MudSkeleton SkeletonType="SkeletonType.Circle"
Width="56px"
Height="56px" />

<MudSkeleton SkeletonType="SkeletonType.Text"
Width="60%" />

<MudSkeleton SkeletonType="SkeletonType.Text"
Width="85%" />

<MudSkeleton SkeletonType="SkeletonType.Text"
Width="75%" />

<MudSkeleton SkeletonType="SkeletonType.Text"
Width="90%" />
<MudSkeleton SkeletonType="SkeletonType.Circle" Width="56px" Height="56px" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="60%" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="85%" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="75%" />
<MudSkeleton SkeletonType="SkeletonType.Text" Width="90%" />
</MudStack>
}
else if (_loadError is not null)
{
<MudAlert Severity="Severity.Error"
Variant="Variant.Outlined"
Class="my-3">
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="my-3">
@_loadError
</MudAlert>

<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Refresh"
OnClick="LoadProfileAsync">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Refresh" OnClick="LoadProfileAsync">
Try Again
</MudButton>
}
else if (_profile is null)
{
<MudAlert Severity="Severity.Warning"
Variant="Variant.Outlined"
Class="my-3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Class="my-3">
Profile information is currently unavailable.
</MudAlert>
}
Expand Down Expand Up @@ -239,14 +222,22 @@

<div class="lab-card-header">
<div>
<h2>Sign-in Identifiers</h2>
<p>
Identifiers associated with your account.
</p>
<h2>
Sign-in Identifiers

@if (!_identifiersLoading && _identifiersError is null)
{
<MudChip T="string" Size="Size.Small" Color="@(IdentifierLimitReached ? Color.Warning : Color.Default)"
Variant="Variant.Outlined" Class="ml-2">
@ActiveIdentifierCount/@MaxIdentifiers
</MudChip>
}
</h2>

<p>Identifiers associated with your account.</p>
</div>

<MudIcon Icon="@Icons.Material.Filled.AlternateEmail"
Color="Color.Primary" />
<MudIcon Icon="@Icons.Material.Filled.AlternateEmail" Color="Color.Primary" />
</div>

<div class="lab-identifier-list">
Expand Down Expand Up @@ -458,6 +449,10 @@
private string? _identifiersError;
private bool _isSandboxOwner;

private int MaxIdentifiers => ServerOptions.Value.Identifiers.Behavior.MaxIdentifiersPerUser;
private int ActiveIdentifierCount => _identifiers.Count;
private bool IdentifierLimitReached => ActiveIdentifierCount >= MaxIdentifiers;

private static string FormatValue(string? value) => string.IsNullOrWhiteSpace(value) ? "Not provided" : value;

protected override async Task OnInitializedAsync()
Expand Down
32 changes: 32 additions & 0 deletions docs/content/client-sdk/identifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,38 @@ The server determines whether the new value can be assigned as primary.

Adding an identifier does not automatically establish verified ownership.

### Identifier Limits

The server may restrict how many identifiers a user can maintain.

By default, UltimateAuth allows up to **10 active identifiers per user**. Applications may configure a different global limit or optional limits for individual identifier types.

These limits apply to both self-service and administrative identifier operations.

When adding an identifier, the server validates the configured limits before accepting the operation.

Possible validation errors include:

| Error Code | Description |
|---|---|
| `identifier_total_limit_exceeded` | The maximum number of active identifiers has been reached |
| `identifier_type_limit_exceeded` | The maximum number of active identifiers of this type has been reached |

Applications should display an appropriate message when an addition is rejected.

For example:

- **Total limit reached:** "You have reached the maximum number of identifiers allowed for your account."
- **Type limit reached:** "You have reached the maximum number of identifiers allowed for this type."

Soft-deleted identifiers do not count toward active limits.

However, deleting an identifier does not necessarily make its value available for reuse. Identifier uniqueness and identifier capacity are separate rules.

The server remains authoritative when enforcing these limits. Client applications should not rely solely on local identifier counts to determine whether an addition will succeed.

For configuration details, see [Server Options](/docs/configuration/server-options).

## 6. Identifier Uniqueness and Normalization

UltimateAuth normalizes identifier values and enforces uniqueness within the relevant tenant boundary.
Expand Down
63 changes: 63 additions & 0 deletions docs/content/configuration/server-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,69 @@ Identifier normalization and uniqueness are particularly important because they

Use the provided options rather than implementing inconsistent normalization independently in the client.

### Identifier Limits

UltimateAuth supports configurable limits on the number of identifiers associated with each user.

By default, a user can have up to **10 active identifiers**, regardless of their type.

```csharp
options.Identifiers.Behavior.MaxIdentifiersPerUser = 10;
```

This is a global per-user limit. It applies to the combined number of usernames, email addresses, phone numbers, and custom identifiers.

#### Optional Per-Type Limits

Applications can optionally define limits for individual identifier types:

```csharp
options.Identifiers.Behavior.MaxIdentifiersPerUser = 10;

options.Identifiers.Behavior.MaxIdentifiersPerType = new()
{
[UserIdentifierType.Username] = 2,
[UserIdentifierType.Email] = 5,
[UserIdentifierType.Phone] = 3
};
```

**Per-type limits are not configured by default.**

When no per-type limit is specified, only the global limit applies.

For example, with the configuration above, a user may have up to five email addresses, but the combined number of all active identifiers cannot exceed ten.

#### Default Behavior

| Option | Default | Description |
|---|---|---|
| `MaxIdentifiersPerUser` | `10` | Maximum total active identifiers per user |
| `MaxIdentifiersPerType` | Empty dictionary | Optional limits for individual identifier types |

The following rules apply:

- Primary and non-primary identifiers count equally.
- Soft-deleted identifiers do not count toward active limits.
- Self-service and administrative operations follow the same limits.
- Per-type limits do not override the global limit.
- Existing identifier behavior options remain applicable.

For example, `AllowMultipleUsernames = false` still prevents multiple usernames, even when the configured username limit is greater than one.

#### Limit Validation

When an identifier addition exceeds a configured limit, the operation is rejected with a validation error.

| Error Code | Meaning |
|---|---|
| `identifier_total_limit_exceeded` | The user has reached the global identifier limit |
| `identifier_type_limit_exceeded` | The user has reached the configured limit for that identifier type |

Applications should handle these errors and display appropriate feedback.

Identifier limits are enforced by the Users application layer rather than individual persistence stores.

## 18. User Profile Options

`options.UserProfile` controls profile-related capabilities.
Expand Down
20 changes: 20 additions & 0 deletions docs/content/plugin-domains/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ UltimateAuth treats identifiers as independent identity records rather than assu

This supports applications with different identity requirements without changing the underlying authentication model.

### Identifier Capacity and Limits

UltimateAuth allows users to maintain multiple identifiers while providing configurable limits to prevent uncontrolled identifier growth.

The default configuration allows **10 active identifiers per user**, shared across all identifier types.

Applications can optionally configure additional per-type limits when their identity model requires them.

For example, an application may allow several email addresses and phone numbers while restricting the number of usernames.

Identifier limits are independent of identifier uniqueness, ownership verification, and primary designation.

A primary identifier consumes the same capacity as a non-primary identifier. Soft-deleted identifiers do not consume active capacity, although their normalized values may remain reserved under the configured uniqueness rules.

Limits apply consistently to self-service and administrative identifier additions.

These rules belong to the Users application layer, keeping persistence implementations independent of application-specific identifier policies.

See [Server Options](/docs/configuration/server-options) for configuration examples.

### Identifier Properties

The reference identifier model distinguishes several important properties:
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Users.Contracts;

namespace CodeBeam.UltimateAuth.Server.Options;

public sealed class UAuthIdentifierBehaviorOptions
{
Expand All @@ -16,6 +18,9 @@ public sealed class UAuthIdentifierBehaviorOptions
public bool AllowAdminOverride { get; set; } = true;
public bool AllowUserOverride { get; set; } = true;

public int MaxIdentifiersPerUser { get; set; } = 10;
public Dictionary<UserIdentifierType, int> MaxIdentifiersPerType { get; set; } = new();

internal UAuthIdentifierBehaviorOptions Clone() => new()
{
AllowUsernameChange = AllowUsernameChange,
Expand All @@ -26,6 +31,9 @@ public sealed class UAuthIdentifierBehaviorOptions
RequireEmailVerification = RequireEmailVerification,
RequirePhoneVerification = RequirePhoneVerification,
AllowAdminOverride = AllowAdminOverride,
AllowUserOverride = AllowUserOverride
AllowUserOverride = AllowUserOverride,

MaxIdentifiersPerUser = MaxIdentifiersPerUser,
MaxIdentifiersPerType = new Dictionary<UserIdentifierType, int>(MaxIdentifiersPerType)
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,38 @@ public sealed class UAuthServerUserIdentifierOptionsValidator : IValidateOptions
{
public ValidateOptionsResult Validate(string? name, UAuthServerOptions options)
{
var errors = new List<string>();

if (!options.Identifiers.Behavior.AllowAdminOverride && !options.Identifiers.Behavior.AllowUserOverride)
{
return ValidateOptionsResult.Fail("Both AllowAdminOverride and AllowUserOverride cannot be false. " +
errors.Add("Both AllowAdminOverride and AllowUserOverride cannot be false. " +
"At least one actor must be able to manage user identifiers.");
}

return ValidateOptionsResult.Success;
var behavior = options.Identifiers.Behavior;

if (behavior.MaxIdentifiersPerUser < 1)
{
errors.Add("Identifiers.Behavior.MaxIdentifiersPerUser must be greater than zero.");
}

if (behavior.MaxIdentifiersPerType is null)
{
errors.Add("Identifiers.Behavior.MaxIdentifiersPerType cannot be null.");
}
else
{
foreach (var (type, limit) in behavior.MaxIdentifiersPerType)
{
if (!Enum.IsDefined(type) || limit < 1)
{
errors.Add($"Invalid identifier limit for {type}. Limit must be greater than zero.");
}
}
}

return errors.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(errors);
}
}
Loading
Loading