diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index c1910ba6..cd8f9614 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -31,6 +31,7 @@ + diff --git a/docs/content/configuration/client-options.md b/docs/content/configuration/client-options.md index 929b5a64..6ea2e2b0 100644 --- a/docs/content/configuration/client-options.md +++ b/docs/content/configuration/client-options.md @@ -1236,4 +1236,4 @@ The current client registration validates options when they are resolved, rather - **Server Options** — Configure authentication security, sessions, tokens, and endpoints. - **Advanced Configuration** — Explore effective options, mode resolution, and extension points. - **Client Authentication** — Implement login, PKCE, refresh, and logout flows. -- **Client Session Management** — Manage authenticated sessions and revocation. \ No newline at end of file +- **Client Session Management** — Manage authenticated sessions and revocation. diff --git a/docs/content/configuration/server-options.md b/docs/content/configuration/server-options.md index 1f2c7dcd..ba10573b 100644 --- a/docs/content/configuration/server-options.md +++ b/docs/content/configuration/server-options.md @@ -14,6 +14,8 @@ UltimateAuth composes its Core-defined options directly into `UAuthServerOptions This guide covers the available configuration groups, their defaults, practical examples, and the distinction between configured and effective runtime behavior. +Built-in HTTP throttling is configured through `options.RateLimiting`. See [Rate Limiting](/docs/security/rate-limiting) for defaults, middleware ownership, proxy configuration and native-policy integration. + ## 1. Register UltimateAuth Server Configure the server through `AddUltimateAuthServer()`: diff --git a/docs/content/getting-started/real-world-setup.md b/docs/content/getting-started/real-world-setup.md index 4e8d16cd..aa295625 100644 --- a/docs/content/getting-started/real-world-setup.md +++ b/docs/content/getting-started/real-world-setup.md @@ -4,7 +4,7 @@ order: 4 group: getting-started --- -# 🏗️ Real-World Setup +# Real-World Setup **One authentication framework. Different deployment models.** @@ -1008,7 +1008,45 @@ Remember that effective mode defaults and mode-specific overrides are applied af For detailed configuration behavior, see Server Options and Advanced Configuration. -## 12. Development and Production Differences +## 12. Client IP Address & Reverse Proxy + +UltimateAuth uses ASP.NET Core's resolved client IP address for device information and IP-based rate limiting. It does not directly trust `X-Forwarded-For` or `CF-Connecting-IP` headers. + +If your application runs behind Caddy, Nginx, or another reverse proxy, configure ASP.NET Core to trust that proxy. + +In `Program.cs`, before `builder.Build()`: + +```csharp +using System.Net; +using Microsoft.AspNetCore.HttpOverrides; + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto; + + // Replace with your actual Caddy or Nginx proxy IP. + options.KnownProxies.Add(IPAddress.Parse("172.20.0.5")); +}); +``` + +Then enable the middleware **before** UltimateAuth and HTTPS redirection: + +```csharp +app.UseForwardedHeaders(); + +app.UseHttpsRedirection(); +app.UseUltimateAuthWithAspNetCore(); +``` + +The example IP `172.20.0.5` represents a trusted reverse proxy, not the visitor's IP address. Replace it with your actual proxy IP. For Docker deployments, ensure the proxy address is stable or configure a narrowly scoped trusted network. + +**Important:** Never trust arbitrary forwarded headers or clear the trusted proxy lists to accept all sources. If your application connects directly to Kestrel without a reverse proxy, no forwarded headers configuration is required. + +For multi-proxy deployments, configure the complete trusted proxy chain and forwarding behavior according to your infrastructure. + +## 13. Development and Production Differences The samples intentionally prioritize visibility and repeatability. @@ -1049,7 +1087,7 @@ Configure persistent signing keys for authentication modes that issue JWTs. Development signing key providers are not a substitute for production key management. -## 13. Deployment Checklist +## 14. Deployment Checklist Before deploying, verify the following: @@ -1064,9 +1102,10 @@ Before deploying, verify the following: - [ ] Client and server endpoint URLs match the deployment. - [ ] Session expiration, refresh, logout, and revocation have been tested. - [ ] Protected resources enforce server-side authorization. +- [ ] Reverse proxy headers are configured correctly if applicable. - [ ] Authentication state and persistence survive the expected application lifecycle. -## 14. Choosing Your Architecture +## 15. Choosing Your Architecture **Start with one server and one client. Connect more clients whenever you need them.** diff --git a/docs/content/security/rate-limiting.md b/docs/content/security/rate-limiting.md new file mode 100644 index 00000000..6b1301b9 --- /dev/null +++ b/docs/content/security/rate-limiting.md @@ -0,0 +1,638 @@ +--- +title: Rate Limiting +order: 8 +group: security +--- + +# Rate Limiting + +UltimateAuth provides built-in HTTP endpoint rate limiting using ASP.NET Core's native rate limiting infrastructure, together with client-side handling of HTTP 429 responses. + +Rate limiting helps reduce excessive authentication requests, credential attacks, recovery abuse, and unnecessary session traffic without introducing a separate persistence dependency. + +The server enforces request limits. UltimateAuth clients recognize throttled responses, preserve valid authentication state, and defer selected background operations when appropriate. + +No additional rate limiting package is required. + +## Getting started + +Rate limiting is enabled by default when using the integrated ASP.NET Core middleware helper. + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.RateLimiting.Standard.PermitLimit = 240; + options.RateLimiting.Login.PermitLimit = 40; + + options.RateLimiting.Endpoints[UAuthEndpoint.Refresh] = new() + { + PermitLimit = 1200, + Window = TimeSpan.FromMinutes(1) + }; +}); +``` + +Configure the middleware pipeline: + +```csharp +app.UseForwardedHeaders(); +app.UseRouting(); + +app.UseUltimateAuthWithAspNetCore(); + +app.UseAntiforgery(); + +app.MapUltimateAuthEndpoints(); +app.MapUAuthHub(); +``` + +Import `CodeBeam.UltimateAuth.Server.RateLimiting` for rate limiting rules and endpoint identifiers. + +Configure forwarded headers only when appropriate for the deployment, using explicitly trusted proxies and networks. + +Rate limiting options are also available through the `UltimateAuth:Server:RateLimiting` configuration section. The existing server options binding behavior applies, including configuration-file precedence. + +Rate limiting settings are configured at startup. They are not automatically scoped to individual clients or authentication modes. + +## Middleware integration + +The integrated helper exposes a single method with two optional parameters: + +```csharp +public static IApplicationBuilder UseUltimateAuthWithAspNetCore( + this IApplicationBuilder app, + bool? enableCors = null, + bool useRateLimiting = true) +``` + +The default middleware order is: + +1. CORS, when enabled or required by UAuthHub +2. UltimateAuth context middleware +3. ASP.NET Core authentication +4. ASP.NET Core rate limiting +5. ASP.NET Core authorization + +This places the rate limiter after authentication, allowing authenticated endpoint policies to use validated UltimateAuth actor information. + +### Configuration examples + +```csharp +// Automatic CORS selection; rate limiting enabled +app.UseUltimateAuthWithAspNetCore(); + +// Explicit CORS; rate limiting enabled +app.UseUltimateAuthWithAspNetCore(enableCors: true); + +// Automatic CORS; rate limiting disabled +app.UseUltimateAuthWithAspNetCore(useRateLimiting: false); + +// Explicit configuration of both options +app.UseUltimateAuthWithAspNetCore(enableCors: true, useRateLimiting: true); +``` + +`enableCors = null` automatically enables CORS when required by UAuthHub. Setting it to `false` does not remove the host's responsibility to configure CORS when needed. + +The standalone UltimateAuth middleware helper does not install ASP.NET Core authentication. If authentication has not already established a validated UltimateAuth actor, rate limiting partitioning falls back to the connection IP. + +### Existing application rate limiters + +If the application already installs its own `UseRateLimiter()` middleware, disable the helper-owned installation: + +```csharp +app.UseRouting(); +app.UseAuthentication(); + +app.UseUltimateAuthWithAspNetCore(useRateLimiting: false); + +app.UseRateLimiter(); +``` + +This illustrates middleware ownership rather than a universal pipeline layout. Hosts must preserve the required routing, authentication, CORS, and authorization order and avoid duplicate middleware registration. + +UltimateAuth cannot detect a separately installed native rate limiting middleware instance. + +Repeated calls to the UltimateAuth helper install at most one helper-owned rate limiter on the same application builder. Other middleware installed by the helper is not deduplicated. + +An application builder created with `ApplicationBuilder.New()` has its own middleware installation lifecycle. + +Disabling UltimateAuth's built-in endpoint policies does not disable an application-owned global limiter or native application policies. + +## Rules and default limits + +UltimateAuth uses native sliding-window limiters with six segments and no request queue. + +The default rule groups are: + +| Rule | Default | Typical operations | +|---|---|---| +| Sensitive | 30 requests/minute | Sensitive credential and recovery operations | +| Standard | 120 requests/minute | Authenticated management operations | +| Frequent | 600 requests/minute | Validation, refresh, logout, authorization checks | +| Login | 20 requests/minute/IP | Shared credential-verification budget | + +These are starting defaults, not recommended universal production limits. Adjust them based on application traffic, expected concurrency, and deployment conditions. + +Group rules act as templates for separate endpoint budgets. For example, validation and refresh do not share a counter merely because both use the Frequent group. + +### Shared login budget + +The Login rule is shared by: + +- Login +- TryLogin +- PkceComplete +- PkceTryComplete + +These credential-verification operations share the same budget for a partition. + +Configure the shared limit through: + +```csharp +options.RateLimiting.Login.PermitLimit = 40; +``` + +An explicit endpoint override replaces that endpoint's rule and separates it from the shared login budget. + +### Additional sensitive defaults + +Certain operations have stricter defaults than the Sensitive group: + +| Operation | Default | +|---|---| +| Anonymous registration | 10 requests/10 minutes/IP | +| Anonymous credential reset initiation | 5 requests/10 minutes/IP | +| Anonymous credential reset completion | 10 requests/10 minutes/IP | +| Self identifier verification | 10 requests/10 minutes/IP | + +These numeric defaults take precedence over the Sensitive group's numeric settings. + +For example, increasing `Sensitive.PermitLimit` does not automatically increase the anonymous reset initiation limit. + +Use an explicit endpoint override to change these limits. + +## Per-endpoint configuration + +Individual endpoint rules can be configured using `RateLimiting.Endpoints`. + +For example, limit session validation to two requests per minute: + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() + { + PermitLimit = 2, + Window = TimeSpan.FromMinutes(1) + }; +}); +``` + +The third request within the applicable sliding window receives HTTP 429 when the budget is exhausted. + +This example is useful for testing client behavior, but two validations per minute may be too restrictive for a real application with automatic session validation. + +Endpoint overrides are complete replacement rules, not partial changes merged with group numeric settings. + +### Policy precedence + +UltimateAuth resolves endpoint policies in the following order: + +1. Native application endpoint/group rate limiting metadata, including native disable metadata +2. Explicit `Endpoints[id]` replacement +3. Shared Login rule for credential-verification operations +4. Special registration, reset, and verification defaults +5. Endpoint group template + +An explicit endpoint override can replace a built-in rule, select an application-owned named policy, or disable the built-in policy for that endpoint. + +All UltimateAuth endpoint mappings have explicit classifications. Endpoint registration fails when a newly mapped endpoint is missing its required classification. + +## Disabling rate limiting + +To disable UltimateAuth's built-in endpoint policies globally: + +```csharp +options.RateLimiting.Enabled = false; +``` + +To disable an individual built-in rule: + +```csharp +options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() +{ + Enabled = false +}; +``` + +A disabled UltimateAuth rule does not add native `DisableRateLimiting` metadata. + +Application-owned policies and global limiters may still apply. + +To prevent the integrated helper from installing the native middleware: + +```csharp +app.UseUltimateAuthWithAspNetCore(useRateLimiting: false); +``` + +These controls have different responsibilities: + +- `RateLimiting.Enabled` controls UltimateAuth's built-in policies. +- Individual rule `Enabled` settings control specific built-in rules. +- `useRateLimiting` controls whether the integrated helper installs the middleware. + +## Using application-owned policies + +Applications can use their own native ASP.NET Core policies instead of an UltimateAuth built-in rule. + +```csharp +builder.Services.AddRateLimiter(options => +{ + options.AddPolicy("my-auth-policy"); +}); + +builder.Services.AddUltimateAuthServer(options => +{ + options.RateLimiting.Endpoints[UAuthEndpoint.UserCreateAnonymous] = new() + { + PolicyName = "my-auth-policy" + }; +}); +``` + +Named policies must be registered with the native rate limiting options used by the middleware. + +UltimateAuth's built-in policies are inline native policies and do not reserve application policy names. + +Endpoint policies do not automatically compose. Native ASP.NET Core endpoint/group precedence applies. + +Native `DisableRateLimiting` metadata also follows ASP.NET Core semantics and can bypass an application global limiter. + +### Rejection callbacks + +Built-in UltimateAuth inline policies produce their own generic HTTP 429 response. + +They do not invoke the application's middleware-level `OnRejected` callback. + +Application-owned named policies can use native rejection behavior. When a named policy has no policy-level rejection callback, the effective native middleware `OnRejected` callback applies. + +If an application needs complete ownership of rejection responses for a particular endpoint, use an application-owned policy. + +UltimateAuth does not replace application global limiters or application rejection settings. + +## HTTP 429 responses + +When a built-in UltimateAuth policy rejects a request, the server returns: + +- HTTP status `429 Too Many Requests` +- Problem Details content +- Stable detail code `rate_limited` +- `Cache-Control: no-store` +- `Retry-After` when the native limiter supplies retry guidance + +A representative response is: + +```json +{ + "title": "Too Many Requests", + "status": 429, + "detail": "rate_limited" +} +``` + +The exact Problem Details payload may include additional fields. + +`Retry-After` is optional. Applications must not assume that every HTTP 429 response contains it. + +Rejected requests do not execute their endpoint handlers. Consequently, a request rejected at this layer does not consume a reset or PKCE proof, rotate refresh credentials, clear logout cookies, or increment login failure counts through the rejected handler. + +Authentication and other middleware executing before the rate limiter may still perform work. + +### User-facing messages + +`rate_limited` is a stable machine-readable error code, not a localized user-facing message. + +Applications can map it to a message such as: + +> Too many requests. Please wait a moment before trying again. + +If a reliable retry delay is available, the application may display it. + +Automatic background operations should generally handle throttling silently rather than repeatedly notifying the user. + +## Client HTTP 429 handling + +UltimateAuth clients recognize HTTP 429 as a temporary request throttle. + +A 429 response does not establish that: + +- The user's credentials are invalid +- The session has been revoked +- A token has expired +- The user is unauthorized +- Reauthentication is required + +**A throttled request must not, by itself, clear a valid authenticated session.** + +UltimateAuth uses its existing client result and error contracts rather than introducing a separate rate limiting exception hierarchy. + +### Client result contracts + +The following contracts carry throttling information where applicable: + +- `UAuthTransportException` +- `TryLoginResult` +- `TryPkceLoginResult` +- `RefreshResult` +- `UAuthResult` +- `UAuthProblem` + +The stable error code is `rate_limited`. + +Depending on the contract, it is exposed through `Code`, `ErrorCode`, or `Problem.Detail`. + +HTTP status remains 429. + +`UAuthResult.Http.IsTooManyRequests` distinguishes throttling from unauthorized and forbidden results. + +`RetryAfter` provides an optional `TimeSpan` where supported. + +Transport DTOs preserve the raw `Retry-After` header across the JavaScript bridge. + +The existing `IUAuthTryResult` interface provides default metadata properties so existing implementations are not required to add new members. + +The public `IFlowClient` method signatures remain unchanged. + +## Client behavior by operation + +| Operation | HTTP 429 behavior | +|---|---| +| Validation | Throws a transport exception with status 429 and throttling metadata | +| Blazor authentication state | Preserves the current identity, claims, and last successful validation time | +| Refresh | Returns an unsuccessful result with status 429 and `Outcome = None` | +| Automatic refresh | Defers subsequent attempts without triggering reauthentication | +| Login preview | Returns a distinguishable throttled result rather than invalid credentials | +| PKCE preview | Returns a distinguishable throttled result | +| Try-and-commit | Does not submit the final commit after a throttled preview | +| Logout | Does not report successful logout or navigate on a confirmed 429 | +| Authorization | Returns a throttled failure, not a Forbidden result | +| Profile, user, credential, and recovery operations | Return unsuccessful results without publishing success events | +| Resource requests | Preserve HTTP 429 information without changing authentication state | + +### Validation + +`ValidateAsync` throws `UAuthTransportException` for HTTP 429 before attempting to deserialize a successful authentication result. + +Blazor's `UAuthStateManager.EnsureAsync` recognizes this condition and preserves the current authentication snapshot. + +It does not: + +- Clear the current identity +- Remove claims +- Treat the user as anonymous +- Mark the session freshly validated +- Update the last successful validation timestamp + +Subsequent validation attempts, including forced validation calls, are deferred during the cooldown. + +A genuine invalid, expired, or revoked validation result continues to follow the normal state-clearing behavior. + +### Refresh + +A throttled refresh returns an unsuccessful `RefreshResult` with: + +```text +StatusCode: 429 +ErrorCode: rate_limited +Outcome: None +``` + +Misleading authentication-outcome headers are ignored on a throttled response. + +Repeated refresh calls during the cooldown return the throttled result without issuing another HTTP request. + +Automatic refresh scheduling respects both the configured refresh interval and the throttling deadline. + +HTTP 429 does not cause reauthentication events, logout, or automatic navigation. + +A genuine refresh HTTP 401 can still produce `ReauthRequired`. + +### Login and PKCE + +Login and PKCE preview operations expose throttling distinctly from invalid credentials. + +When a TryAndCommit preview is throttled, the client does not submit the final commit form. + +However, final browser-navigation form submissions retain their existing navigation semantics. + +For `LoginAsync`, DirectCommit, `CompletePkceLoginAsync`, and the final TryAndCommit submission, the browser owns the resulting HTTP response. + +If that final POST receives HTTP 429, the browser may display the server response page. + +The JavaScript client cannot reliably return the final navigated response's `Retry-After` metadata through the existing navigation contract. + +Preview success or form submission completion must not be interpreted as confirmation that a session was issued. + +### Logout + +Logout uses an observed form POST so the client can distinguish a confirmed successful response from HTTP 429. + +On HTTP 429: + +- No successful logout event is published. +- No success navigation occurs. +- The client does not clear authentication state solely because of throttling. + +On confirmed success, the existing logout event can update client authentication state. + +Logout navigation is different from login navigation: the observed request can follow a successful server redirect and then navigate without replaying the logout POST. + +Cross-origin logout still requires compatible browser credentials, CORS configuration, and redirect behavior. + +A network or redirect failure may leave the server-side logout outcome uncertain. The SDK reports an error rather than claiming success or automatically replaying the operation. + +Applications can explicitly revalidate when connectivity permits. + +### Custom client transports + +`IUAuthRequestClient` supports an observed form request overload: + +```csharp +SendFormAsync(endpoint, form, navigateOnSuccess, ct) +``` + +The overload has a default implementation that delegates to the original form request method. + +Existing custom transport implementations therefore retain their original interface requirements. + +However, the default implementation does not provide navigation behavior automatically. + +Custom adapters that support logout navigation should implement the observed overload and preserve HTTP status and `Retry-After` metadata. + +## Retry-After and cooldown behavior + +UltimateAuth clients support standard `Retry-After` values expressed as: + +- Delta seconds +- HTTP-date values + +Parsing uses invariant culture and UTC. + +Missing, malformed, or inaccessible values remain null in application results. Past dates produce a zero retry delay. + +For internal maintenance scheduling, the SDK uses: + +- A conservative 30-second fallback when retry guidance is unavailable +- A one-second minimum to avoid immediate retry loops +- Bounded timer intervals when waiting for long delays + +Long retry guidance is honored by the current implementation. + +The SDK does not automatically retry arbitrary application operations. + +In particular, it does not automatically replay login, logout, recovery, credential mutations, or administrative changes after throttling. + +For cross-origin browser clients, the server must expose `Retry-After` through CORS if JavaScript is expected to read that header. + +When the header is not accessible, the client follows its missing-header behavior. + +## Authentication state safety + +Rate limiting is deliberately separated from authentication validity. + +A throttled request does not: + +- Extend token expiration +- Renew credentials +- Update successful validation timestamps +- Create a trusted identity +- Revoke an existing session +- Clear browser storage automatically +- Publish successful mutation events + +`UAuthStateManager` remains responsible for authentication state transitions. + +`SessionCoordinator` remains responsible for automatic refresh scheduling and genuine reauthentication handling. + +`UAuthAuthenticationStateProvider` projects the retained state to Blazor. + +Explicit local actions such as `Clear` and `OnLogoutAsync`, and authoritative revocation or mutation events, retain their existing behavior. + +## Partitioning and trust boundaries + +UltimateAuth chooses rate limiting partitions using trusted server-side information. + +Credential entry, anonymous registration and recovery, identifier availability, PKCE, Hub entry, and self-proof verification use the connection IP even when a cookie is present. + +Other operations use validated UltimateAuth tenant and actor information when available, otherwise the connection IP. + +The following do not independently establish a trusted authenticated partition: + +- Mere presence of a session cookie +- Usernames or target user identifiers +- Unvalidated tokens +- Device identifiers +- Client profile hints +- Raw tenant selectors +- Principals from unrelated authentication authorities + +Authenticated actor partitions include validated tenant identity. + +Anonymous IP budgets span tenants, preventing a client from multiplying its quota simply by rotating tenant selectors. + +IPv4-mapped IPv6 addresses are normalized to IPv4. + +Requests without an available connection address share an unknown-address partition. + +UltimateAuth does not trust arbitrary forwarding headers. + +Configure trusted proxies, forwarded headers, and ingress restrictions at the application or infrastructure level. + +Applications using another identity authority should configure an appropriate native policy rather than assuming UltimateAuth can validate that authority's principal. + +## Persistence and distributed deployment + +Built-in counters are maintained in application memory. + +They are not stored in UltimateAuth's persistence providers, EF Core, or a database. + +This means: + +- Counters reset when the application process restarts. +- Each application instance maintains its own counters. +- Browser refreshes do not reset server counters. +- Sliding windows replenish capacity over time. +- Multiple application instances do not automatically share a global request budget. + +For distributed deployments requiring fleet-wide limits, use an ingress gateway, WAF, or custom native policy backed by shared infrastructure. + +Built-in native partitions have idle cleanup, but do not provide a hard partition-cardinality bound. + +Large-scale IP rotation, IPv6 address churn, NAT sharing, and botnet traffic require additional operational consideration. + +Rate limiting does not replace account lockout, proof-attempt limits, anti-enumeration responses, or other authentication security controls. + +Direct service calls and Blazor Server circuit operations are outside HTTP endpoint throttling unless the application protects them separately. + +## Testing rate limiting + +A simple development test is to temporarily limit validation to two requests per minute: + +```csharp +builder.Services.AddUltimateAuthServer(options => +{ + options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() + { + PermitLimit = 2, + Window = TimeSpan.FromMinutes(1) + }; +}); +``` + +After starting the application and authenticating, send repeated requests to: + +```http +POST /auth/validate +``` + +The expected server behavior is: + +| Request | Expected result | +|---|---| +| First permitted request | Normal validation | +| Second permitted request | Normal validation | +| Request exceeding the budget | HTTP 429 | +| Request after capacity replenishes | Normal validation | + +Actual results depend on the partition's existing traffic and sliding-window state. + +When testing through an UltimateAuth client, the client may defer additional validation requests after receiving HTTP 429. This is expected and should be tested separately from server enforcement. + +Also verify that: + +- Authentication state remains intact after throttling. +- Other independently limited operations continue to work. +- Automatic refresh does not enter an immediate retry loop. +- Login preview does not confuse throttling with invalid credentials. +- Logout does not report success after a confirmed 429. +- No successful mutation event is published for a rejected operation. + +Remove temporary low-limit overrides before deploying to production. + +### Client IP Address & Reverse Proxies + +UltimateAuth uses ASP.NET Core's resolved client IP address for IP-based rate limiting. It does not directly trust forwarded IP headers. + +When deploying behind Caddy, Nginx, Cloudflare, or another reverse proxy, ensure that ASP.NET Core's Forwarded Headers Middleware is correctly configured with trusted proxies or networks. + +Without proper configuration, multiple users may be identified by the same proxy IP address and share rate limits. + +See [Client IP Address & Reverse Proxy Setup](/docs/getting-started/real-world-setup#10-client-ip-address--reverse-proxy) for configuration examples. + +## Related documentation + +- [Server Options](/docs/configuration/server-options) +- [Client Options](/docs/configuration/client-options) +- [Authentication Model](/docs/concepts/authentication-model) + +Rate limiting is one layer of UltimateAuth's security model. It should be combined with appropriate credential policies, session validation, trusted proxy configuration, and deployment-level protection. diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/client-options.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/client-options.json index d5a6794b..c11b7c67 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/client-options.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/client-options.json @@ -414,6 +414,6 @@ "Level": 0 } ], - "SourceHash": "FE74B982178EDB99198EC90811ED91660E95FD2EE3D445361B61236F850B608A", + "SourceHash": "8B65E4A762A493F3F5C6A4F6978EE5FCCCE7678BD388239B876754178E5EE3D0", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/server-options.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/server-options.json index a5f73fbd..841d876f 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/server-options.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/configuration/server-options.json @@ -1,7 +1,7 @@ { "Slug": "configuration/server-options", "Title": "Server Options", - "Html": "\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E is the primary configuration surface for UltimateAuth Server.\u003C/p\u003E\n\u003Cp\u003EIt controls authentication behavior, session lifecycles, token issuance, credential security, tenant resolution, endpoint exposure, and integration with UAuthHub.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth composes its Core-defined options directly into \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E. When using UltimateAuth Server, you configure both shared authentication behavior and server-specific features through the same options object.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the available configuration groups, their defaults, practical examples, and the distinction between configured and effective runtime behavior.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-ultimateauth-server\u0022\u003E1. Register UltimateAuth Server\u003C/h2\u003E\n\u003Cp\u003EConfigure the server through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe configuration delegate receives:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server registration also supports an optional policy configuration delegate.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(\n configure: options =\u0026gt;\n {\n options.Login.MaxFailedAttempts = 5;\n },\n configurePolicies: policies =\u0026gt;\n {\n // Register application access policies.\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server automatically registers its Core infrastructure. You do not need to call \u003Ccode\u003EAddUltimateAuth()\u003C/code\u003E separately.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-from-appsettings.json\u0022\u003EConfiguration from appsettings.json\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth Server binds the configuration section:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n },\n \u0026quot;Session\u0026quot;: {\n \u0026quot;Lifetime\u0026quot;: \u0026quot;7.00:00:00\u0026quot;\n },\n \u0026quot;Token\u0026quot;: {\n \u0026quot;AccessTokenLifetime\u0026quot;: \u0026quot;00:10:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EASP.NET Core represents \u003Ccode\u003ETimeSpan\u003C/code\u003E values using strings such as \u003Ccode\u003E00:15:00\u003C/code\u003E for fifteen minutes and \u003Ccode\u003E7.00:00:00\u003C/code\u003E for seven days.\u003C/p\u003E\n\u003Cp\u003EThe configuration section is bound after the registration delegate, so configuration provider values take precedence for matching properties.\u003C/p\u003E\n\u003Cp\u003EFor configuration source ordering and environment-specific overrides, see \u003Ca href=\u0022/docs/configuration/configuration\u0022\u003EConfiguration Overview\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-architecture\u0022\u003E2. Server Options Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E contains two categories of configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022core-defined-options\u0022\u003ECore-defined options\u003C/h3\u003E\n\u003Cp\u003EThese types are defined in UltimateAuth Core and composed into the server options:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthLoginOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and lockout behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthSessionOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession lifetime and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthTokenOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccess and refresh token behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthPkceOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE authorization code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthMultiTenantOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant identification and resolution\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthPaginationOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPagination defaults and limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication lifecycle callbacks\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-specific-options\u0022\u003EServer-specific options\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERestrict permitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescribe the relationship between UAuthHub and the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub integration and flow settings\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECookie names, security attributes, and lifetime behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession identifier sources and precedence\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEndpoint groups and disabled actions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary credential selection for UI and API requests\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication credential delivery and redirect behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession invalidation after credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery code and token settings\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier behavior, normalization, and uniqueness\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUsername, email, and phone validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier types allowed for login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile capabilities\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication navigation behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional diagnostic response details\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdvanced configuration hooks are also available, including \u003Ccode\u003EConfigureMode()\u003C/code\u003E and \u003Ccode\u003EOnConfigureEndpoints\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-options\u0022\u003E3. Login Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Login\u003C/code\u003E controls login attempt limits, lockout behavior, and related security settings.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Login.FailureWindow = TimeSpan.FromMinutes(15);\n options.Login.ExtendLockOnFailure = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxFailedAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E10\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EFailed login threshold; \u003Ccode\u003E0\u003C/code\u003E disables lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELockoutDuration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E15 minutes\u003C/td\u003E\n\u003Ctd\u003ELockout duration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFailureWindow\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E15 minutes\u003C/td\u003E\n\u003Ctd\u003EWindow for counting failed attempts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EExtendLockOnFailure\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExtend lockout when another attempt fails during lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIncludeFailureDetails\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude detailed authentication failure information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryLoginDuration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E180 seconds\u003C/td\u003E\n\u003Ctd\u003EDuration associated with the try-login flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-restrict-login-failure-details\u0022\u003EExample: Restrict Login Failure Details\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.IncludeFailureDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis setting is useful when an application does not want to expose detailed lockout information to callers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important\u0022\u003EImportant\u003C/h3\u003E\n\u003Cp\u003ELockout configuration is not equivalent to complete network-level rate limiting.\u003C/p\u003E\n\u003Cp\u003EProduction deployments should also consider infrastructure protections against abusive traffic and distributed login attempts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-options\u0022\u003E4. Session Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Session\u003C/code\u003E controls session lifetime, expiration, sliding behavior, and session-chain limits.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Session.SlidingExpiration = true;\n options.Session.IdleTimeout = TimeSpan.FromDays(3);\n options.Session.MaxLifetime = TimeSpan.FromDays(30);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-lifetime-properties\u0022\u003ESession Lifetime Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E7 days\u003C/td\u003E\n\u003Ctd\u003EStandard session lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional absolute lifetime limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESlidingExpiration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAllow eligible activity or refresh operations to extend expiration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional inactivity timeout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E5 minutes\u003C/td\u003E\n\u003Ctd\u003EMinimum interval between last-seen updates\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings have different purposes.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ELifetime\u003C/code\u003E determines the standard lifetime assigned to a session.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMaxLifetime\u003C/code\u003E establishes an optional hard boundary that cannot be extended indefinitely.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E limits how long a session can remain inactive.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E reduces unnecessary persistence operations caused by frequent activity updates.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-chain-properties\u0022\u003ESession-Chain Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerUser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved user-level chain limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxSessionsPerChain\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E100\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession-chain rotation limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerPlatform\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved platform-specific chain limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPlatformCategories\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved platform group definitions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerCategory\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved category-specific chain limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableIpBinding\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved IP binding setting\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableUserAgentBinding\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved User-Agent binding setting\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeviceMismatchBehavior\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EReject\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved device mismatch behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation limitation:\u003C/strong\u003E The source identifies several device limits and binding settings as reserved for future enforcement. Setting these properties must not be treated as proof that the corresponding protection is active.\u003C/p\u003E\n\u003Cp\u003EFor example, do not rely on:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Session.MaxChainsPerUser = 1;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eas a production guarantee that only one device chain can exist unless the current runtime implementation has been verified to enforce that restriction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-specific-session-defaults\u0022\u003EMode-Specific Session Defaults\u003C/h3\u003E\n\u003Cp\u003ESession settings can also be affected by authentication mode defaults.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EPureOpaque\u003C/code\u003E supplies a longer idle timeout and touch interval when those values have not been configured.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EHybrid\u003C/code\u003E explicitly sets \u003Ccode\u003ETouchInterval\u003C/code\u003E to \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for the effective-options resolution order.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-options\u0022\u003E5. Token Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Token\u003C/code\u003E configures access and refresh token behavior.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Token.Issuer = \u0026quot;MyApplication\u0026quot;;\n options.Token.Audience = \u0026quot;MyApplicationApi\u0026quot;;\n options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-1\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueJwt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable JWT issuance where supported by the effective mode\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueOpaque\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable opaque token issuance where supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueRefresh\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable refresh token issuance where supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAccessTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E10 minutes\u003C/td\u003E\n\u003Ctd\u003EAccess token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E7 days\u003C/td\u003E\n\u003Ctd\u003ERefresh token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOpaqueIdBytes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E32\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERandom byte length used for opaque token identifiers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshTokenConcurrentRequestWindow\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E2 seconds\u003C/td\u003E\n\u003Ctd\u003EConcurrent duplicate refresh classification window\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssuer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuth\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EJWT issuer\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAudience\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EJWT audience\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddJwtIdClaim\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude a JWT identifier claim\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeyId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional signing key identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-token-concurrency-window\u0022\u003ERefresh Token Concurrency Window\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Token.RefreshTokenConcurrentRequestWindow = TimeSpan.FromSeconds(2);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis option controls how the server distinguishes a closely timed duplicate refresh request from a confirmed replay.\u003C/p\u003E\n\u003Cp\u003EA duplicate refresh request is still rejected.\u003C/p\u003E\n\u003Cp\u003EThe window affects the replay-response classification, including whether the token family should be revoked.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E allow the same consumed refresh token to be used successfully more than once.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-defaults-may-override-token-values\u0022\u003EMode Defaults May Override Token Values\u003C/h3\u003E\n\u003Cp\u003EThe effective \u003Ccode\u003EHybrid\u003C/code\u003E configuration explicitly sets:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EToken.AccessTokenLifetime = TimeSpan.FromMinutes(10);\nToken.RefreshTokenLifetime = TimeSpan.FromDays(7);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ETherefore, a different value configured globally may be replaced when the effective mode is resolved.\u003C/p\u003E\n\u003Cp\u003ETo customize the lifetime for \u003Ccode\u003EHybrid\u003C/code\u003E, use:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis distinction is essential when configuring token lifetimes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pkce-options\u0022\u003E6. PKCE Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Pkce\u003C/code\u003E configures the lifetime of PKCE authorization codes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Pkce.AuthorizationCodeLifetimeSeconds = 120;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorizationCodeLifetimeSeconds\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E120\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELifetime of a PKCE authorization code\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EPKCE is particularly important for browser-based clients such as Blazor WebAssembly.\u003C/p\u003E\n\u003Cp\u003EA shorter authorization code lifetime reduces the period during which an intercepted or exposed code could potentially be used.\u003C/p\u003E\n\u003Cp\u003EHowever, the value must remain long enough for legitimate authorization exchanges to complete.\u003C/p\u003E\n\u003Cp\u003EThe server\u0027s PKCE configuration is separate from the client\u0027s PKCE flow settings.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003E7. Authentication Modes\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports authentication mode resolution based on the client profile.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedModes\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis property restricts the modes the server is permitted to use.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict Allowed Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a restriction, not a mode selector.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved by \u003Ccode\u003EIEffectiveAuthModeResolver\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-mode-resolution\u0022\u003EDefault Mode Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBlazorServer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EWebServer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBlazorWasm\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaui\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EApi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe current implementation has mode templates for \u003Ccode\u003EPureOpaque\u003C/code\u003E, \u003Ccode\u003EHybrid\u003C/code\u003E, \u003Ccode\u003ESemiHybrid\u003C/code\u003E, and \u003Ccode\u003EPureJwt\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Ccode\u003ESemiHybrid\u003C/code\u003E and \u003Ccode\u003EPureJwt\u003C/code\u003E are not yet accepted as enabled modes by the current server options validator.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not enable an unsupported mode merely because it exists in the enum or has a mode-default template.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EApi\u003C/code\u003E profile mapping to \u003Ccode\u003EPureJwt\u003C/code\u003E is a current compatibility limitation to consider when deploying API-profile authentication flows.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-mode-specific-behavior\u0022\u003EConfigure Mode-Specific Behavior\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EConfigureMode()\u003C/code\u003E for behavior that should differ by mode.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ConfigureMode(UAuthMode.PureOpaque, mode =\u0026gt;\n {\n mode.Session.IdleTimeout = TimeSpan.FromDays(7);\n });\n\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe mode override runs after the built-in mode defaults.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for details.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-options\u0022\u003E8. Endpoint Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Endpoints\u003C/code\u003E controls endpoint paths, feature groups, and individual action restrictions.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022base-path\u0022\u003EBase Path\u003C/h3\u003E\n\u003Cp\u003EThe default server endpoint base path is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E/auth\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure it through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;/auth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EChanging the base path can help avoid route conflicts when integrating UltimateAuth into an existing application.\u003C/p\u003E\n\u003Cp\u003EThe client must use compatible endpoint paths.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-groups\u0022\u003EEndpoint Groups\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthentication\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserLifecycle\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser lifecycle endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentials\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorization\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthorization endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.Authentication = true;\n options.Endpoints.Session = true;\n options.Endpoints.Authorization = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDisabling an endpoint group controls endpoint exposure. It does not automatically remove the underlying domain services or invalidate previously issued credentials.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-individual-actions\u0022\u003EDisable Individual Actions\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EDisabledActions\u003C/code\u003E is a collection of action identifiers:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.DisabledActions.Add(\u0026quot;users.create.anonymous\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe string must match the action identifier used by the server\u0027s endpoint registration and action policy.\u003C/p\u003E\n\u003Cp\u003EDo not pass a C# constant name as a string unless that exact text is also the registered action value.\u003C/p\u003E\n\u003Cp\u003EFor example, these are not necessarily equivalent:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026quot;UAuthActions.Users.Create.Anonymous\u0026quot;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eand:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026quot;users.create.anonymous\u0026quot;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the actual action value defined by the corresponding \u003Ccode\u003EUAuthActions\u003C/code\u003E constant.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-hardening\u0022\u003EEndpoint Hardening\u003C/h3\u003E\n\u003Cp\u003EDisable endpoints that are not required by the application.\u003C/p\u003E\n\u003Cp\u003EFor example, an application that delegates user provisioning to administrators may choose to disable anonymous user creation.\u003C/p\u003E\n\u003Cp\u003EEndpoint exposure is a separate concern from authorization: an exposed endpoint must still enforce the applicable security policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-resolution\u0022\u003E9. Session Resolution\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.SessionResolution\u003C/code\u003E controls how incoming requests provide session identifiers.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports four resolution sources:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EBearer credentials.\u003C/li\u003E\n\u003Cli\u003ECustom headers.\u003C/li\u003E\n\u003Cli\u003ECookies.\u003C/li\u003E\n\u003Cli\u003EQuery parameters.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-configuration\u0022\u003EDefault Configuration\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableBearer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableHeader\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableCookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableQuery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHeaderName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EX-UAuth-Session\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EQueryParameterName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Esession_id\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default precedence order is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EBearer\nHeader\nCookie\nQuery\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-resolution-sources\u0022\u003EConfigure Resolution Sources\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.SessionResolution.EnableBearer = true;\n options.SessionResolution.EnableHeader = false;\n options.SessionResolution.EnableCookie = true;\n options.SessionResolution.EnableQuery = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example disables custom-header and query-string session resolution.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-resolution-order\u0022\u003EChange Resolution Order\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.SessionResolution.Order =\n [\n \u0026quot;Cookie\u0026quot;,\n \u0026quot;Bearer\u0026quot;,\n \u0026quot;Header\u0026quot;,\n \u0026quot;Query\u0026quot;\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResolution order matters when a request contains multiple candidate credentials.\u003C/p\u003E\n\u003Cp\u003EThe selected credential must still pass authentication and session validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-recommendation\u0022\u003ESecurity Recommendation\u003C/h3\u003E\n\u003Cp\u003EAvoid query-string credentials unless the application has a specific, well-understood requirement.\u003C/p\u003E\n\u003Cp\u003EURLs may be recorded in browser history, reverse-proxy logs, analytics systems, and other infrastructure.\u003C/p\u003E\n\u003Cp\u003EFor most browser and API integrations, cookies or authorization headers are preferable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022cookie-configuration\u0022\u003E10. Cookie Configuration\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Cookie\u003C/code\u003E controls cookies used for authentication credentials.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth defines three cookie groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Cookie.Session\noptions.Cookie.AccessToken\noptions.Cookie.RefreshToken\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-cookie-names\u0022\u003EDefault Cookie Names\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECookie\u003C/th\u003E\n\u003Cth\u003EDefault name\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESession\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euas\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAccess token\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euat\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERefresh token\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euar\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default cookie options use:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EHttpOnly = true\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ESecurePolicy = CookieSecurePolicy.Always\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPath = \u0026quot;/\u0026quot;\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDomain = null\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EMaxAge = null\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\u003Ccode\u003ESameSite\u003C/code\u003E is nullable and can be resolved through the applicable cookie and deployment policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022customize-cookie-names\u0022\u003ECustomize Cookie Names\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Cookie.Session.Name = \u0026quot;myapp_session\u0026quot;;\n options.Cookie.RefreshToken.Name = \u0026quot;myapp_refresh\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-cookie-scope\u0022\u003EConfigure Cookie Scope\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Cookie.Session.Path = \u0026quot;/\u0026quot;;\n options.Cookie.Session.Domain = null;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA null domain produces a host-only cookie.\u003C/p\u003E\n\u003Cp\u003ESetting a parent domain can broaden cookie availability to subdomains and should be done only when necessary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cookie-security\u0022\u003ECookie Security\u003C/h3\u003E\n\u003Cp\u003EAvoid disabling \u003Ccode\u003EHttpOnly\u003C/code\u003E or relaxing secure transport requirements for authentication cookies.\u003C/p\u003E\n\u003Cp\u003ECookie settings interact with:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe deployment mode.\u003C/li\u003E\n\u003Cli\u003ECross-site requests.\u003C/li\u003E\n\u003Cli\u003EBrowser cookie policies.\u003C/li\u003E\n\u003Cli\u003ECredential delivery configuration.\u003C/li\u003E\n\u003Cli\u003EHTTPS requirements.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor cross-site deployments, configure cookie behavior together with CORS and the relevant browser security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-deployment-mode\u0022\u003E11. Hub Deployment Mode\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.HubDeploymentMode\u003C/code\u003E describes how UAuthHub is deployed relative to the application.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.HubDeploymentMode = UAuthHubDeploymentMode.Embedded;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-modes\u0022\u003EAvailable Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEmbedded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub and application share the same origin\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIntegrated\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub is separate but within the same-site boundary\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EExternal\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub is hosted on a different site or domain\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthHubDeploymentMode.Integrated\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022embedded\u0022\u003EEmbedded\u003C/h3\u003E\n\u003Cp\u003ESuitable for applications hosting UltimateAuth within the same application and origin.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.HubDeploymentMode = UAuthHubDeploymentMode.Embedded;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022integrated\u0022\u003EIntegrated\u003C/h3\u003E\n\u003Cp\u003ESuitable for deployments such as:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eauth.example.com\napp.example.com\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Ewhere the authentication service and application are separate origins within the same site.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022external\u0022\u003EExternal\u003C/h3\u003E\n\u003Cp\u003ESuitable for a separate authentication provider domain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.HubDeploymentMode = UAuthHubDeploymentMode.External;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EExternal deployment requires careful coordination of cookie policies, redirects, allowed origins, and browser restrictions.\u003C/p\u003E\n\u003Cp\u003EThe deployment mode influences authentication transport behavior; it is not merely descriptive metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-options\u0022\u003E12. Hub Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Hub\u003C/code\u003E configures UAuthHub integration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-2\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EClientBaseAddress\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional client application base address\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEmpty set\u003C/td\u003E\n\u003Ctd\u003EAllowed client origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlowLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E5 minutes\u003C/td\u003E\n\u003Ctd\u003ELifetime of Hub flow artifacts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginPath\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E/login\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub login page path\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Hub.ClientBaseAddress = \u0026quot;https://app.example.com\u0026quot;;\n options.Hub.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n options.Hub.FlowLifetime = TimeSpan.FromMinutes(5);\n options.Hub.LoginPath = \u0026quot;/login\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub flow lifetime is not the same as the authenticated session lifetime.\u003C/p\u003E\n\u003Cp\u003EHub artifacts support authentication orchestration and should generally be short-lived.\u003C/p\u003E\n\u003Cp\u003EOnly trusted origins should be included in \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022primary-credential-policy\u0022\u003E13. Primary Credential Policy\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.PrimaryCredential\u003C/code\u003E controls which credential kind is preferred for UI and API requests.\u003C/p\u003E\n\u003Cp\u003EThe defaults are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind.Session\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EApi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind.AccessToken\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-1\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.PrimaryCredential.Ui = PrimaryTokenKind.Session;\n options.PrimaryCredential.Api = PrimaryTokenKind.AccessToken;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows UltimateAuth to distinguish between session-oriented browser interactions and access-token-oriented API interactions.\u003C/p\u003E\n\u003Cp\u003EThe primary credential policy does not bypass authentication validation.\u003C/p\u003E\n\u003Cp\u003EIt determines which credential kind should be preferred within the applicable request-processing rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-session-policy\u0022\u003E14. Credential Session Policy\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.CredentialSessionPolicy\u003C/code\u003E determines what happens to active session chains after credential changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-3\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnSelfChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser changes their own credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnAdminChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAdministrator changes a user\u0027s credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnRecovery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery completes\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-2\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-behaviors\u0022\u003EAvailable Behaviors\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EEffect\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EKeep existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EKeep the current chain and revoke other applicable chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor recovery flows, \u003Ccode\u003EPreserveCurrent\u003C/code\u003E is not always meaningful because the user may not have an authenticated current session.\u003C/p\u003E\n\u003Cp\u003EThe current implementation treats this situation using the more restrictive \u003Ccode\u003ERevokeAll\u003C/code\u003E behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-recommendation-1\u0022\u003ESecurity Recommendation\u003C/h3\u003E\n\u003Cp\u003EPreserving the current session after a normal password change can provide a reasonable user experience.\u003C/p\u003E\n\u003Cp\u003ERevoking all sessions after administrative credential changes or account recovery is generally more conservative.\u003C/p\u003E\n\u003Cp\u003EChoose the policy according to the application\u0027s security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-reset-options\u0022\u003E15. Credential Reset Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.ResetCredential\u003C/code\u003E configures credential recovery proof lifetimes and attempt limits.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022defaults\u0022\u003EDefaults\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETokenValidity\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E30 minutes\u003C/td\u003E\n\u003Ctd\u003EReset token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeValidity\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E10 minutes\u003C/td\u003E\n\u003Ctd\u003ENumeric reset code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum permitted recovery attempts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeLength\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E6\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENumeric reset code length\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-3\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ResetCredential.TokenValidity = TimeSpan.FromMinutes(20);\n options.ResetCredential.CodeValidity = TimeSpan.FromMinutes(5);\n options.ResetCredential.MaxAttempts = 3;\n options.ResetCredential.CodeLength = 6;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECodeLength\u003C/code\u003E applies to numeric reset codes, not token-based recovery proofs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-security-boundary\u0022\u003EImportant Security Boundary\u003C/h3\u003E\n\u003Cp\u003EReset proofs must be delivered through a trusted server-side mechanism.\u003C/p\u003E\n\u003Cp\u003EDo not return recovery tokens to anonymous callers simply to simplify a client-side reset form.\u003C/p\u003E\n\u003Cp\u003EThe application should provide the appropriate \u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E implementation for supported recovery flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-identifier-options\u0022\u003E16. Login Identifier Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.LoginIdentifiers\u003C/code\u003E determines which identifier types can be used for login.\u003C/p\u003E\n\u003Cp\u003EThe default allowed types are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUserIdentifierType.Username\nUserIdentifierType.Email\nUserIdentifierType.Phone\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-login-identifier-types\u0022\u003ERestrict Login Identifier Types\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.LoginIdentifiers.AllowedTypes =\n new HashSet\u0026lt;UserIdentifierType\u0026gt;\n {\n UserIdentifierType.Email\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis configures email as the allowed login identifier type.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022require-verification\u0022\u003ERequire Verification\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.LoginIdentifiers.RequireVerificationForEmail = true;\n options.LoginIdentifiers.RequireVerificationForPhone = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings control whether the corresponding identifiers must be verified before being accepted for login.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-resolvers\u0022\u003ECustom Resolvers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableCustomResolvers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECustomResolversFirst\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese options control the participation and ordering of custom login identifier resolvers.\u003C/p\u003E\n\u003Cp\u003EAn identifier existing on a user account does not necessarily mean it is permitted for login.\u003C/p\u003E\n\u003Cp\u003EIdentifier management and login eligibility are separate concerns.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-validation\u0022\u003E17. Identifier Validation\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.IdentifierValidation\u003C/code\u003E provides rules for usernames, email addresses, and phone numbers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022username-rules\u0022\u003EUsername Rules\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.IdentifierValidation.UserName.Enabled = true;\n options.IdentifierValidation.UserName.MinLength = 3;\n options.IdentifierValidation.UserName.MaxLength = 32;\n options.IdentifierValidation.UserName.AllowedRegex = \u0026quot;^[a-zA-Z0-9._-]\u002B$\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-validation-rules\u0022\u003EDefault Validation Rules\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EIdentifier\u003C/th\u003E\n\u003Cth\u003EEnabled\u003C/th\u003E\n\u003Cth\u003EMinimum length\u003C/th\u003E\n\u003Cth\u003EMaximum length\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUsername\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E64\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EEmail\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E256\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPhone\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E24\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUsername validation additionally supports \u003Ccode\u003EAllowedRegex\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default username expression is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EREGEX\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-regex\u0022\u003E^[a-zA-Z0-9._-]\u002B$\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-behavior\u0022\u003EIdentifier Behavior\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Identifiers\u003C/code\u003E contains additional groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior\noptions.Identifiers.Validation\noptions.Identifiers.Normalization\noptions.Identifiers.Uniqueness\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese configure identifier behavior beyond the top-level validation rules.\u003C/p\u003E\n\u003Cp\u003EIdentifier normalization and uniqueness are particularly important because they affect how duplicate identifiers are detected.\u003C/p\u003E\n\u003Cp\u003EUse the provided options rather than implementing inconsistent normalization independently in the client.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-limits\u0022\u003EIdentifier Limits\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth supports configurable limits on the number of identifiers associated with each user.\u003C/p\u003E\n\u003Cp\u003EBy default, a user can have up to \u003Cstrong\u003E10 active identifiers\u003C/strong\u003E, regardless of their type.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior.MaxIdentifiersPerUser = 10;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is a global per-user limit. It applies to the combined number of usernames, email addresses, phone numbers, and custom identifiers.\u003C/p\u003E\n\u003Ch4 id=\u0022optional-per-type-limits\u0022\u003EOptional Per-Type Limits\u003C/h4\u003E\n\u003Cp\u003EApplications can optionally define limits for individual identifier types:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior.MaxIdentifiersPerUser = 10;\n\noptions.Identifiers.Behavior.MaxIdentifiersPerType = new()\n{\n [UserIdentifierType.Username] = 2,\n [UserIdentifierType.Email] = 5,\n [UserIdentifierType.Phone] = 3\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EPer-type limits are not configured by default.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EWhen no per-type limit is specified, only the global limit applies.\u003C/p\u003E\n\u003Cp\u003EFor 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.\u003C/p\u003E\n\u003Ch4 id=\u0022default-behavior\u0022\u003EDefault Behavior\u003C/h4\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxIdentifiersPerUser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E10\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum total active identifiers per user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxIdentifiersPerType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEmpty dictionary\u003C/td\u003E\n\u003Ctd\u003EOptional limits for individual identifier types\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe following rules apply:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EPrimary and non-primary identifiers count equally.\u003C/li\u003E\n\u003Cli\u003ESoft-deleted identifiers do not count toward active limits.\u003C/li\u003E\n\u003Cli\u003ESelf-service and administrative operations follow the same limits.\u003C/li\u003E\n\u003Cli\u003EPer-type limits do not override the global limit.\u003C/li\u003E\n\u003Cli\u003EExisting identifier behavior options remain applicable.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EAllowMultipleUsernames = false\u003C/code\u003E still prevents multiple usernames, even when the configured username limit is greater than one.\u003C/p\u003E\n\u003Ch4 id=\u0022limit-validation\u0022\u003ELimit Validation\u003C/h4\u003E\n\u003Cp\u003EWhen an identifier addition exceeds a configured limit, the operation is rejected with a validation error.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EError Code\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eidentifier_total_limit_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe user has reached the global identifier limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eidentifier_type_limit_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe user has reached the configured limit for that identifier type\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EApplications should handle these errors and display appropriate feedback.\u003C/p\u003E\n\u003Cp\u003EIdentifier limits are enforced by the Users application layer rather than individual persistence stores.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-profile-options\u0022\u003E18. User Profile Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.UserProfile\u003C/code\u003E controls profile-related capabilities.\u003C/p\u003E\n\u003Cp\u003EThe current option is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.UserProfile.EnableMultiProfile\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIts default is \u003Ccode\u003Efalse\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enable-multiple-profiles\u0022\u003EEnable Multiple Profiles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.UserProfile.EnableMultiProfile = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to use UltimateAuth\u0027s multiple-profile capability where supported by the user domain.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may maintain separate profile contexts for different purposes.\u003C/p\u003E\n\u003Cp\u003EMulti-profile support does not create separate user identities or independent authentication accounts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022multi-tenant-options\u0022\u003E19. Multi-Tenant Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.MultiTenant\u003C/code\u003E controls whether tenant-aware behavior is enabled and how tenant identifiers are resolved.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-4\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnabled\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable multi-tenant behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENormalizeToLowercase\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENormalize tenant identifiers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableRoute\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from routes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableHeader\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from headers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableDomain\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from hosts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHeaderName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EX-Tenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant header name\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-header-based-tenant-resolution\u0022\u003EExample: Header-Based Tenant Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.Enabled = true;\n options.MultiTenant.EnableHeader = true;\n options.MultiTenant.HeaderName = \u0026quot;X-Tenant\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-domain-based-resolution\u0022\u003EExample: Domain-Based Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.Enabled = true;\n options.MultiTenant.EnableDomain = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EMultiple resolution mechanisms can be enabled.\u003C/p\u003E\n\u003Cp\u003EThe server composes the configured tenant resolvers according to its resolution infrastructure.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-boundary\u0022\u003ESecurity Boundary\u003C/h3\u003E\n\u003Cp\u003ETenant identification is not tenant authorization.\u003C/p\u003E\n\u003Cp\u003EA request providing another tenant identifier must not gain access to that tenant without appropriate authorization.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for tenant isolation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination-options\u0022\u003E20. Pagination Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Pagination\u003C/code\u003E controls pagination defaults and maximum page sizes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDefaultPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E250\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E1000\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-4\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Pagination.DefaultPageSize = 50;\n options.Pagination.MaxPageSize = 200;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings affect paginated queries exposed by UltimateAuth domain services.\u003C/p\u003E\n\u003Cp\u003EFor administrative interfaces, choosing a moderate page size can reduce unnecessary data transfer and processing.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigation-options\u0022\u003E21. Navigation Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Navigation\u003C/code\u003E configures automatic authentication navigation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-5\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableAutomaticNavigationRedirect\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAccessDeniedResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-automatic-redirects\u0022\u003EDisable Automatic Redirects\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.EnableAutomaticNavigationRedirect = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-login-redirect\u0022\u003ECustom Login Redirect\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.LoginResolver = context =\u0026gt;\n {\n return \u0026quot;/account/login\u0026quot;;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-access-denied-redirect\u0022\u003ECustom Access Denied Redirect\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.AccessDeniedResolver = context =\u0026gt;\n {\n return \u0026quot;/account/access-denied\u0026quot;;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese delegates receive the current \u003Ccode\u003EHttpContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe application should return valid, trusted navigation destinations.\u003C/p\u003E\n\u003Cp\u003EAvoid constructing redirects from unvalidated user-controlled URLs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-response-options\u0022\u003E22. Authentication Response Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.AuthResponse\u003C/code\u003E controls how authentication credentials and redirect behavior are represented in responses.\u003C/p\u003E\n\u003Cp\u003EThe available groups are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AuthResponse.SessionIdDelivery\noptions.AuthResponse.AccessTokenDelivery\noptions.AuthResponse.RefreshTokenDelivery\n\noptions.AuthResponse.Login\noptions.AuthResponse.Logout\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings govern credential delivery and authentication response behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-mode-specific-response-defaults\u0022\u003EImportant: Mode-Specific Response Defaults\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth provides response templates for authentication modes.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EPureOpaque\u003C/code\u003E uses a session-oriented cookie response, while \u003Ccode\u003EHybrid\u003C/code\u003E combines session and token delivery behavior.\u003C/p\u003E\n\u003Cp\u003EThe effective response may therefore depend on the resolved authentication mode and flow.\u003C/p\u003E\n\u003Cp\u003EDo not assume that a single globally configured response delivery setting applies unchanged to every authentication mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003ECredential delivery settings determine whether sensitive authentication material is exposed through cookies, headers, or other response mechanisms.\u003C/p\u003E\n\u003Cp\u003EOnly customize them when the application has a clear requirement and the selected transport is appropriate for the client platform.\u003C/p\u003E\n\u003Cp\u003EFor advanced response customization, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022diagnostics-options\u0022\u003E23. Diagnostics Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Diagnostics\u003C/code\u003E provides optional diagnostic behavior.\u003C/p\u003E\n\u003Cp\u003EThe current property is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Diagnostics.EnableRefreshDetails\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIts default is \u003Ccode\u003Efalse\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-5\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Diagnostics.EnableRefreshDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen enabled, the option allows additional refresh-related diagnostic response information, such as the \u003Ccode\u003EX-UAuth-Refresh\u003C/code\u003E header.\u003C/p\u003E\n\u003Cp\u003EThis is primarily useful during development and debugging.\u003C/p\u003E\n\u003Cp\u003EAvoid enabling detailed authentication diagnostics in production unless the exposure is understood and justified.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-lifecycle-events\u0022\u003E24. Authentication Lifecycle Events\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Events\u003C/code\u003E exposes server-side authentication lifecycle callbacks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-events\u0022\u003EAvailable Events\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Events.OnAnyEvent\noptions.Events.OnUserLoggedIn\noptions.Events.OnUserLoggedOut\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-login-logging\u0022\u003EExample: Login Logging\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Events.OnUserLoggedIn = context =\u0026gt;\n {\n // Record the successful login.\n return Task.CompletedTask;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-global-event-handler\u0022\u003EExample: Global Event Handler\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Events.OnAnyEvent = context =\u0026gt;\n {\n // Process an authentication lifecycle event.\n return Task.CompletedTask;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ELifecycle events are intended for lightweight reactions such as logging, metrics, and notifications.\u003C/p\u003E\n\u003Cp\u003EThey should not replace security decisions, authentication validation, or application policies.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for event and extension guidance.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022effective-server-options\u0022\u003E25. Effective Server Options\u003C/h2\u003E\n\u003Cp\u003EThe configured \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E instance is the base configuration.\u003C/p\u003E\n\u003Cp\u003EAt runtime, UltimateAuth can resolve:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EEffectiveUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current resolution process is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EConfigured UAuthServerOptions\n |\n v\n Resolve auth mode\n |\n v\n Clone base options\n |\n v\n Apply mode defaults\n |\n v\n Apply ConfigureMode overrides\n |\n v\n EffectiveUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis means that the effective settings used for a \u003Ccode\u003EHybrid\u003C/code\u003E client can differ from those used for a \u003Ccode\u003EPureOpaque\u003C/code\u003E client.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-6\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromDays(7);\n\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n\n options.ConfigureMode(UAuthMode.PureOpaque, mode =\u0026gt;\n {\n mode.Session.IdleTimeout = TimeSpan.FromDays(3);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe original options remain the common starting point.\u003C/p\u003E\n\u003Cp\u003EMode defaults and overrides determine the effective values for the resolved mode.\u003C/p\u003E\n\u003Cp\u003EFor implementation details and inspection of effective options, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-and-configuration-safety\u0022\u003E26. Validation and Configuration Safety\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth Server registers multiple options validators and enables startup validation.\u003C/p\u003E\n\u003Cp\u003EThese validators cover areas including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin settings.\u003C/li\u003E\n\u003Cli\u003ESession settings.\u003C/li\u003E\n\u003Cli\u003EToken settings.\u003C/li\u003E\n\u003Cli\u003EPKCE.\u003C/li\u003E\n\u003Cli\u003EMulti-tenancy.\u003C/li\u003E\n\u003Cli\u003EIdentifier configuration.\u003C/li\u003E\n\u003Cli\u003ESession resolution.\u003C/li\u003E\n\u003Cli\u003EPagination.\u003C/li\u003E\n\u003Cli\u003EGeneral server configuration.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-session-lifetime\u0022\u003EExample: Invalid Session Lifetime\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.Zero;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server rejects an invalid session lifetime under the applicable validation rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-base-path\u0022\u003EExample: Invalid Base Path\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;//auth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe general server validator rejects paths containing \u003Ccode\u003E//\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unsupported-modes\u0022\u003EUnsupported Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureJwt\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current validator rejects explicitly enabling \u003Ccode\u003EPureJwt\u003C/code\u003E because that mode is not yet implemented as a supported server mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-limitations\u0022\u003EValidation Limitations\u003C/h3\u003E\n\u003Cp\u003EStartup validation protects the configured options according to the registered validators.\u003C/p\u003E\n\u003Cp\u003EIt does not guarantee that every custom service implementation or mode-specific override is secure.\u003C/p\u003E\n\u003Cp\u003EWhen using advanced customization, test the resulting effective configuration and authentication flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-configuration-example\u0022\u003E27. Complete Configuration Example\u003C/h2\u003E\n\u003Cp\u003EThe following example combines several commonly used server settings.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n // Authentication modes\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n\n // Login security\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Login.IncludeFailureDetails = false;\n\n // Sessions\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Session.MaxLifetime = TimeSpan.FromDays(30);\n\n // Token identity\n options.Token.Issuer = \u0026quot;MyApplication\u0026quot;;\n options.Token.Audience = \u0026quot;MyApplicationApi\u0026quot;;\n\n // Hybrid-specific token lifetime\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n });\n\n // PKCE\n options.Pkce.AuthorizationCodeLifetimeSeconds = 120;\n\n // Hub deployment\n options.HubDeploymentMode = UAuthHubDeploymentMode.Integrated;\n options.Hub.ClientBaseAddress = \u0026quot;https://app.example.com\u0026quot;;\n options.Hub.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n\n // Credential security\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n\n // Credential recovery\n options.ResetCredential.TokenValidity = TimeSpan.FromMinutes(30);\n options.ResetCredential.MaxAttempts = 3;\n\n // Identifier login rules\n options.LoginIdentifiers.RequireVerificationForEmail = true;\n\n // Session transport\n options.SessionResolution.EnableCookie = true;\n options.SessionResolution.EnableBearer = true;\n options.SessionResolution.EnableQuery = false;\n\n // Endpoint exposure\n options.Endpoints.Authentication = true;\n options.Endpoints.Session = true;\n options.Endpoints.Credentials = true;\n options.Endpoints.Authorization = true;\n\n // Pagination\n options.Pagination.DefaultPageSize = 50;\n options.Pagination.MaxPageSize = 200;\n\n // Diagnostics\n options.Diagnostics.EnableRefreshDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is an illustrative configuration, not a universal production preset.\u003C/p\u003E\n\u003Cp\u003EApplications should choose session lifetimes, authentication modes, credential transport, tenant behavior, and endpoint exposure according to their own deployment and security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-configuration-mistakes\u0022\u003E28. Common Configuration Mistakes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuring-core-options-separately\u0022\u003EConfiguring Core Options Separately\u003C/h3\u003E\n\u003Cp\u003EWhen using \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E, configure Core-defined options through \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate \u003Ccode\u003EUAuthOptions\u003C/code\u003E configuration does not automatically override the server\u0027s composed options.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-allowedmodes-selects-the-mode\u0022\u003EAssuming AllowedModes Selects the Mode\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a restriction.\u003C/p\u003E\n\u003Cp\u003EMode selection is performed by the authentication mode resolver.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ignoring-mode-defaults\u0022\u003EIgnoring Mode Defaults\u003C/h3\u003E\n\u003Cp\u003EMode-specific defaults can replace globally configured values.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EConfigureMode()\u003C/code\u003E for explicit mode-specific behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enabling-unsupported-modes\u0022\u003EEnabling Unsupported Modes\u003C/h3\u003E\n\u003Cp\u003EAn authentication mode appearing in an enum does not guarantee runtime support.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022treating-reserved-session-options-as-enforced-security-features\u0022\u003ETreating Reserved Session Options as Enforced Security Features\u003C/h3\u003E\n\u003Cp\u003ESome device and session-chain options are reserved for future enforcement.\u003C/p\u003E\n\u003Cp\u003EVerify actual runtime support before relying on them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022exposing-unnecessary-endpoints\u0022\u003EExposing Unnecessary Endpoints\u003C/h3\u003E\n\u003Cp\u003EDisable endpoint groups or actions that the application does not require.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022allowing-query-string-credentials-without-a-specific-need\u0022\u003EAllowing Query-String Credentials Without a Specific Need\u003C/h3\u003E\n\u003Cp\u003EQuery-string credentials can leak through URLs and infrastructure logs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022weakening-cookie-security\u0022\u003EWeakening Cookie Security\u003C/h3\u003E\n\u003Cp\u003EDo not relax secure transport or cookie protections without understanding the consequences.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-configuration-validation-covers-everything\u0022\u003EAssuming Configuration Validation Covers Everything\u003C/h3\u003E\n\u003Cp\u003EStartup validation does not replace testing, infrastructure security, or validation of custom extension implementations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-quick-reference\u0022\u003E29. Server Options Quick Reference\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConfiguration group\u003C/th\u003E\n\u003Cth\u003ETypical use\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession expiration and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EToken issuance and lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPermitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant resolution\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EQuery page limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEndpoint exposure\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential source precedence\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECookie security and scope\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub deployment topology\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUI/API primary credential selection\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential delivery\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession invalidation after credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery rules\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier eligibility\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMulti-profile capability\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication redirects\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERefresh diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication lifecycle callbacks\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EConfigureMode()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMode-specific option overrides\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnConfigureEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAdvanced endpoint customization\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/configuration\u0022\u003EConfiguration Overview\u003C/a\u003E \u2014 Understand the configuration architecture and precedence.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client SDK behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, mode resolution, and extension points.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Manage sessions through the client.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Use the client authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E is the primary configuration surface for UltimateAuth Server.\u003C/p\u003E\n\u003Cp\u003EIt controls authentication behavior, session lifecycles, token issuance, credential security, tenant resolution, endpoint exposure, and integration with UAuthHub.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth composes its Core-defined options directly into \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E. When using UltimateAuth Server, you configure both shared authentication behavior and server-specific features through the same options object.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the available configuration groups, their defaults, practical examples, and the distinction between configured and effective runtime behavior.\u003C/p\u003E\n\u003Cp\u003EBuilt-in HTTP throttling is configured through \u003Ccode\u003Eoptions.RateLimiting\u003C/code\u003E. See \u003Ca href=\u0022/docs/security/rate-limiting\u0022\u003ERate Limiting\u003C/a\u003E for defaults, middleware ownership, proxy configuration and native-policy integration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-ultimateauth-server\u0022\u003E1. Register UltimateAuth Server\u003C/h2\u003E\n\u003Cp\u003EConfigure the server through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe configuration delegate receives:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server registration also supports an optional policy configuration delegate.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(\n configure: options =\u0026gt;\n {\n options.Login.MaxFailedAttempts = 5;\n },\n configurePolicies: policies =\u0026gt;\n {\n // Register application access policies.\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server automatically registers its Core infrastructure. You do not need to call \u003Ccode\u003EAddUltimateAuth()\u003C/code\u003E separately.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-from-appsettings.json\u0022\u003EConfiguration from appsettings.json\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth Server binds the configuration section:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EUltimateAuth:Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n },\n \u0026quot;Session\u0026quot;: {\n \u0026quot;Lifetime\u0026quot;: \u0026quot;7.00:00:00\u0026quot;\n },\n \u0026quot;Token\u0026quot;: {\n \u0026quot;AccessTokenLifetime\u0026quot;: \u0026quot;00:10:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EASP.NET Core represents \u003Ccode\u003ETimeSpan\u003C/code\u003E values using strings such as \u003Ccode\u003E00:15:00\u003C/code\u003E for fifteen minutes and \u003Ccode\u003E7.00:00:00\u003C/code\u003E for seven days.\u003C/p\u003E\n\u003Cp\u003EThe configuration section is bound after the registration delegate, so configuration provider values take precedence for matching properties.\u003C/p\u003E\n\u003Cp\u003EFor configuration source ordering and environment-specific overrides, see \u003Ca href=\u0022/docs/configuration/configuration\u0022\u003EConfiguration Overview\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-architecture\u0022\u003E2. Server Options Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthServerOptions\u003C/code\u003E contains two categories of configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022core-defined-options\u0022\u003ECore-defined options\u003C/h3\u003E\n\u003Cp\u003EThese types are defined in UltimateAuth Core and composed into the server options:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EType\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthLoginOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and lockout behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthSessionOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession lifetime and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthTokenOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAccess and refresh token behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthPkceOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE authorization code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthMultiTenantOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant identification and resolution\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthPaginationOptions\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPagination defaults and limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication lifecycle callbacks\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-specific-options\u0022\u003EServer-specific options\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERestrict permitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EDescribe the relationship between UAuthHub and the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub integration and flow settings\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECookie names, security attributes, and lifetime behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession identifier sources and precedence\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEndpoint groups and disabled actions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPrimary credential selection for UI and API requests\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication credential delivery and redirect behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession invalidation after credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery code and token settings\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier behavior, normalization, and uniqueness\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUsername, email, and phone validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier types allowed for login\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile capabilities\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication navigation behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional diagnostic response details\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EAdvanced configuration hooks are also available, including \u003Ccode\u003EConfigureMode()\u003C/code\u003E and \u003Ccode\u003EOnConfigureEndpoints\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-options\u0022\u003E3. Login Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Login\u003C/code\u003E controls login attempt limits, lockout behavior, and related security settings.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Login.FailureWindow = TimeSpan.FromMinutes(15);\n options.Login.ExtendLockOnFailure = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxFailedAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E10\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EFailed login threshold; \u003Ccode\u003E0\u003C/code\u003E disables lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELockoutDuration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E15 minutes\u003C/td\u003E\n\u003Ctd\u003ELockout duration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFailureWindow\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E15 minutes\u003C/td\u003E\n\u003Ctd\u003EWindow for counting failed attempts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EExtendLockOnFailure\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EExtend lockout when another attempt fails during lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIncludeFailureDetails\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude detailed authentication failure information\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETryLoginDuration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E180 seconds\u003C/td\u003E\n\u003Ctd\u003EDuration associated with the try-login flow\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-restrict-login-failure-details\u0022\u003EExample: Restrict Login Failure Details\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.IncludeFailureDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis setting is useful when an application does not want to expose detailed lockout information to callers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important\u0022\u003EImportant\u003C/h3\u003E\n\u003Cp\u003ELockout configuration is not equivalent to complete network-level rate limiting.\u003C/p\u003E\n\u003Cp\u003EProduction deployments should also consider infrastructure protections against abusive traffic and distributed login attempts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-options\u0022\u003E4. Session Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Session\u003C/code\u003E controls session lifetime, expiration, sliding behavior, and session-chain limits.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Session.SlidingExpiration = true;\n options.Session.IdleTimeout = TimeSpan.FromDays(3);\n options.Session.MaxLifetime = TimeSpan.FromDays(30);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-lifetime-properties\u0022\u003ESession Lifetime Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E7 days\u003C/td\u003E\n\u003Ctd\u003EStandard session lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional absolute lifetime limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESlidingExpiration\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAllow eligible activity or refresh operations to extend expiration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional inactivity timeout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E5 minutes\u003C/td\u003E\n\u003Ctd\u003EMinimum interval between last-seen updates\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings have different purposes.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ELifetime\u003C/code\u003E determines the standard lifetime assigned to a session.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMaxLifetime\u003C/code\u003E establishes an optional hard boundary that cannot be extended indefinitely.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EIdleTimeout\u003C/code\u003E limits how long a session can remain inactive.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ETouchInterval\u003C/code\u003E reduces unnecessary persistence operations caused by frequent activity updates.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-chain-properties\u0022\u003ESession-Chain Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerUser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E0\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved user-level chain limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxSessionsPerChain\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E100\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession-chain rotation limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerPlatform\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved platform-specific chain limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPlatformCategories\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved platform group definitions\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxChainsPerCategory\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved category-specific chain limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableIpBinding\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved IP binding setting\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableUserAgentBinding\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved User-Agent binding setting\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDeviceMismatchBehavior\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EReject\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EReserved device mismatch behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003ECurrent implementation limitation:\u003C/strong\u003E The source identifies several device limits and binding settings as reserved for future enforcement. Setting these properties must not be treated as proof that the corresponding protection is active.\u003C/p\u003E\n\u003Cp\u003EFor example, do not rely on:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Session.MaxChainsPerUser = 1;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eas a production guarantee that only one device chain can exist unless the current runtime implementation has been verified to enforce that restriction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-specific-session-defaults\u0022\u003EMode-Specific Session Defaults\u003C/h3\u003E\n\u003Cp\u003ESession settings can also be affected by authentication mode defaults.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EPureOpaque\u003C/code\u003E supplies a longer idle timeout and touch interval when those values have not been configured.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EHybrid\u003C/code\u003E explicitly sets \u003Ccode\u003ETouchInterval\u003C/code\u003E to \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for the effective-options resolution order.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022token-options\u0022\u003E5. Token Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Token\u003C/code\u003E configures access and refresh token behavior.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Token.Issuer = \u0026quot;MyApplication\u0026quot;;\n options.Token.Audience = \u0026quot;MyApplicationApi\u0026quot;;\n options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n options.Token.RefreshTokenLifetime = TimeSpan.FromDays(7);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-1\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueJwt\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable JWT issuance where supported by the effective mode\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueOpaque\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable opaque token issuance where supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssueRefresh\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable refresh token issuance where supported\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAccessTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E10 minutes\u003C/td\u003E\n\u003Ctd\u003EAccess token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshTokenLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E7 days\u003C/td\u003E\n\u003Ctd\u003ERefresh token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOpaqueIdBytes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E32\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERandom byte length used for opaque token identifiers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERefreshTokenConcurrentRequestWindow\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E2 seconds\u003C/td\u003E\n\u003Ctd\u003EConcurrent duplicate refresh classification window\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIssuer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuth\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EJWT issuer\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAudience\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EUAuthClient\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EJWT audience\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAddJwtIdClaim\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EInclude a JWT identifier claim\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EKeyId\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional signing key identifier\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh-token-concurrency-window\u0022\u003ERefresh Token Concurrency Window\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Token.RefreshTokenConcurrentRequestWindow = TimeSpan.FromSeconds(2);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis option controls how the server distinguishes a closely timed duplicate refresh request from a confirmed replay.\u003C/p\u003E\n\u003Cp\u003EA duplicate refresh request is still rejected.\u003C/p\u003E\n\u003Cp\u003EThe window affects the replay-response classification, including whether the token family should be revoked.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E allow the same consumed refresh token to be used successfully more than once.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022mode-defaults-may-override-token-values\u0022\u003EMode Defaults May Override Token Values\u003C/h3\u003E\n\u003Cp\u003EThe effective \u003Ccode\u003EHybrid\u003C/code\u003E configuration explicitly sets:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EToken.AccessTokenLifetime = TimeSpan.FromMinutes(10);\nToken.RefreshTokenLifetime = TimeSpan.FromDays(7);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ETherefore, a different value configured globally may be replaced when the effective mode is resolved.\u003C/p\u003E\n\u003Cp\u003ETo customize the lifetime for \u003Ccode\u003EHybrid\u003C/code\u003E, use:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis distinction is essential when configuring token lifetimes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pkce-options\u0022\u003E6. PKCE Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Pkce\u003C/code\u003E configures the lifetime of PKCE authorization codes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Pkce.AuthorizationCodeLifetimeSeconds = 120;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorizationCodeLifetimeSeconds\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E120\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELifetime of a PKCE authorization code\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EPKCE is particularly important for browser-based clients such as Blazor WebAssembly.\u003C/p\u003E\n\u003Cp\u003EA shorter authorization code lifetime reduces the period during which an intercepted or exposed code could potentially be used.\u003C/p\u003E\n\u003Cp\u003EHowever, the value must remain long enough for legitimate authorization exchanges to complete.\u003C/p\u003E\n\u003Cp\u003EThe server\u0027s PKCE configuration is separate from the client\u0027s PKCE flow settings.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003E7. Authentication Modes\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth supports authentication mode resolution based on the client profile.\u003C/p\u003E\n\u003Cp\u003EThe server exposes:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedModes\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis property restricts the modes the server is permitted to use.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict Allowed Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a restriction, not a mode selector.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved by \u003Ccode\u003EIEffectiveAuthModeResolver\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-mode-resolution\u0022\u003EDefault Mode Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBlazorServer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EWebServer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EBlazorWasm\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaui\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EApi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe current implementation has mode templates for \u003Ccode\u003EPureOpaque\u003C/code\u003E, \u003Ccode\u003EHybrid\u003C/code\u003E, \u003Ccode\u003ESemiHybrid\u003C/code\u003E, and \u003Ccode\u003EPureJwt\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EHowever, \u003Ccode\u003ESemiHybrid\u003C/code\u003E and \u003Ccode\u003EPureJwt\u003C/code\u003E are not yet accepted as enabled modes by the current server options validator.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EDo not enable an unsupported mode merely because it exists in the enum or has a mode-default template.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EApi\u003C/code\u003E profile mapping to \u003Ccode\u003EPureJwt\u003C/code\u003E is a current compatibility limitation to consider when deploying API-profile authentication flows.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-mode-specific-behavior\u0022\u003EConfigure Mode-Specific Behavior\u003C/h3\u003E\n\u003Cp\u003EUse \u003Ccode\u003EConfigureMode()\u003C/code\u003E for behavior that should differ by mode.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ConfigureMode(UAuthMode.PureOpaque, mode =\u0026gt;\n {\n mode.Session.IdleTimeout = TimeSpan.FromDays(7);\n });\n\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe mode override runs after the built-in mode defaults.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for details.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-options\u0022\u003E8. Endpoint Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Endpoints\u003C/code\u003E controls endpoint paths, feature groups, and individual action restrictions.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022base-path\u0022\u003EBase Path\u003C/h3\u003E\n\u003Cp\u003EThe default server endpoint base path is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E/auth\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure it through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;/auth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EChanging the base path can help avoid route conflicts when integrating UltimateAuth into an existing application.\u003C/p\u003E\n\u003Cp\u003EThe client must use compatible endpoint paths.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-groups\u0022\u003EEndpoint Groups\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthentication\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserLifecycle\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser lifecycle endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser profile endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserIdentifier\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentials\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential management endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthorization\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthorization endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.Authentication = true;\n options.Endpoints.Session = true;\n options.Endpoints.Authorization = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EDisabling an endpoint group controls endpoint exposure. It does not automatically remove the underlying domain services or invalidate previously issued credentials.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-individual-actions\u0022\u003EDisable Individual Actions\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EDisabledActions\u003C/code\u003E is a collection of action identifiers:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.DisabledActions.Add(\u0026quot;users.create.anonymous\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe string must match the action identifier used by the server\u0027s endpoint registration and action policy.\u003C/p\u003E\n\u003Cp\u003EDo not pass a C# constant name as a string unless that exact text is also the registered action value.\u003C/p\u003E\n\u003Cp\u003EFor example, these are not necessarily equivalent:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026quot;UAuthActions.Users.Create.Anonymous\u0026quot;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Eand:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E\u0026quot;users.create.anonymous\u0026quot;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EUse the actual action value defined by the corresponding \u003Ccode\u003EUAuthActions\u003C/code\u003E constant.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022endpoint-hardening\u0022\u003EEndpoint Hardening\u003C/h3\u003E\n\u003Cp\u003EDisable endpoints that are not required by the application.\u003C/p\u003E\n\u003Cp\u003EFor example, an application that delegates user provisioning to administrators may choose to disable anonymous user creation.\u003C/p\u003E\n\u003Cp\u003EEndpoint exposure is a separate concern from authorization: an exposed endpoint must still enforce the applicable security policy.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022session-resolution\u0022\u003E9. Session Resolution\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.SessionResolution\u003C/code\u003E controls how incoming requests provide session identifiers.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports four resolution sources:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EBearer credentials.\u003C/li\u003E\n\u003Cli\u003ECustom headers.\u003C/li\u003E\n\u003Cli\u003ECookies.\u003C/li\u003E\n\u003Cli\u003EQuery parameters.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-configuration\u0022\u003EDefault Configuration\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableBearer\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableHeader\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableCookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableQuery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHeaderName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EX-UAuth-Session\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EQueryParameterName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Esession_id\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default precedence order is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EBearer\nHeader\nCookie\nQuery\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-resolution-sources\u0022\u003EConfigure Resolution Sources\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.SessionResolution.EnableBearer = true;\n options.SessionResolution.EnableHeader = false;\n options.SessionResolution.EnableCookie = true;\n options.SessionResolution.EnableQuery = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis example disables custom-header and query-string session resolution.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022change-resolution-order\u0022\u003EChange Resolution Order\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.SessionResolution.Order =\n [\n \u0026quot;Cookie\u0026quot;,\n \u0026quot;Bearer\u0026quot;,\n \u0026quot;Header\u0026quot;,\n \u0026quot;Query\u0026quot;\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResolution order matters when a request contains multiple candidate credentials.\u003C/p\u003E\n\u003Cp\u003EThe selected credential must still pass authentication and session validation.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-recommendation\u0022\u003ESecurity Recommendation\u003C/h3\u003E\n\u003Cp\u003EAvoid query-string credentials unless the application has a specific, well-understood requirement.\u003C/p\u003E\n\u003Cp\u003EURLs may be recorded in browser history, reverse-proxy logs, analytics systems, and other infrastructure.\u003C/p\u003E\n\u003Cp\u003EFor most browser and API integrations, cookies or authorization headers are preferable.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022cookie-configuration\u0022\u003E10. Cookie Configuration\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Cookie\u003C/code\u003E controls cookies used for authentication credentials.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth defines three cookie groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Cookie.Session\noptions.Cookie.AccessToken\noptions.Cookie.RefreshToken\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-cookie-names\u0022\u003EDefault Cookie Names\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ECookie\u003C/th\u003E\n\u003Cth\u003EDefault name\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESession\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euas\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAccess token\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euat\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERefresh token\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Euar\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default cookie options use:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EHttpOnly = true\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ESecurePolicy = CookieSecurePolicy.Always\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EPath = \u0026quot;/\u0026quot;\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EDomain = null\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EMaxAge = null\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\u003Ccode\u003ESameSite\u003C/code\u003E is nullable and can be resolved through the applicable cookie and deployment policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022customize-cookie-names\u0022\u003ECustomize Cookie Names\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Cookie.Session.Name = \u0026quot;myapp_session\u0026quot;;\n options.Cookie.RefreshToken.Name = \u0026quot;myapp_refresh\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-cookie-scope\u0022\u003EConfigure Cookie Scope\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Cookie.Session.Path = \u0026quot;/\u0026quot;;\n options.Cookie.Session.Domain = null;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA null domain produces a host-only cookie.\u003C/p\u003E\n\u003Cp\u003ESetting a parent domain can broaden cookie availability to subdomains and should be done only when necessary.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cookie-security\u0022\u003ECookie Security\u003C/h3\u003E\n\u003Cp\u003EAvoid disabling \u003Ccode\u003EHttpOnly\u003C/code\u003E or relaxing secure transport requirements for authentication cookies.\u003C/p\u003E\n\u003Cp\u003ECookie settings interact with:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe deployment mode.\u003C/li\u003E\n\u003Cli\u003ECross-site requests.\u003C/li\u003E\n\u003Cli\u003EBrowser cookie policies.\u003C/li\u003E\n\u003Cli\u003ECredential delivery configuration.\u003C/li\u003E\n\u003Cli\u003EHTTPS requirements.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor cross-site deployments, configure cookie behavior together with CORS and the relevant browser security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-deployment-mode\u0022\u003E11. Hub Deployment Mode\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.HubDeploymentMode\u003C/code\u003E describes how UAuthHub is deployed relative to the application.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.HubDeploymentMode = UAuthHubDeploymentMode.Embedded;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-modes\u0022\u003EAvailable Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EMode\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEmbedded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub and application share the same origin\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIntegrated\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub is separate but within the same-site boundary\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EExternal\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub is hosted on a different site or domain\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe default is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUAuthHubDeploymentMode.Integrated\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022embedded\u0022\u003EEmbedded\u003C/h3\u003E\n\u003Cp\u003ESuitable for applications hosting UltimateAuth within the same application and origin.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.HubDeploymentMode = UAuthHubDeploymentMode.Embedded;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022integrated\u0022\u003EIntegrated\u003C/h3\u003E\n\u003Cp\u003ESuitable for deployments such as:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003Eauth.example.com\napp.example.com\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003Ewhere the authentication service and application are separate origins within the same site.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022external\u0022\u003EExternal\u003C/h3\u003E\n\u003Cp\u003ESuitable for a separate authentication provider domain.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.HubDeploymentMode = UAuthHubDeploymentMode.External;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EExternal deployment requires careful coordination of cookie policies, redirects, allowed origins, and browser restrictions.\u003C/p\u003E\n\u003Cp\u003EThe deployment mode influences authentication transport behavior; it is not merely descriptive metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-options\u0022\u003E12. Hub Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Hub\u003C/code\u003E configures UAuthHub integration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-2\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EClientBaseAddress\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EOptional client application base address\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEmpty set\u003C/td\u003E\n\u003Ctd\u003EAllowed client origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EFlowLifetime\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E5 minutes\u003C/td\u003E\n\u003Ctd\u003ELifetime of Hub flow artifacts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginPath\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E/login\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub login page path\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Hub.ClientBaseAddress = \u0026quot;https://app.example.com\u0026quot;;\n options.Hub.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n options.Hub.FlowLifetime = TimeSpan.FromMinutes(5);\n options.Hub.LoginPath = \u0026quot;/login\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub flow lifetime is not the same as the authenticated session lifetime.\u003C/p\u003E\n\u003Cp\u003EHub artifacts support authentication orchestration and should generally be short-lived.\u003C/p\u003E\n\u003Cp\u003EOnly trusted origins should be included in \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022primary-credential-policy\u0022\u003E13. Primary Credential Policy\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.PrimaryCredential\u003C/code\u003E controls which credential kind is preferred for UI and API requests.\u003C/p\u003E\n\u003Cp\u003EThe defaults are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind.Session\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EApi\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryTokenKind.AccessToken\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-1\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.PrimaryCredential.Ui = PrimaryTokenKind.Session;\n options.PrimaryCredential.Api = PrimaryTokenKind.AccessToken;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows UltimateAuth to distinguish between session-oriented browser interactions and access-token-oriented API interactions.\u003C/p\u003E\n\u003Cp\u003EThe primary credential policy does not bypass authentication validation.\u003C/p\u003E\n\u003Cp\u003EIt determines which credential kind should be preferred within the applicable request-processing rules.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-session-policy\u0022\u003E14. Credential Session Policy\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.CredentialSessionPolicy\u003C/code\u003E determines what happens to active session chains after credential changes.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-3\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EPurpose\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnSelfChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUser changes their own credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnAdminChange\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAdministrator changes a user\u0027s credential\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnRecovery\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery completes\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-2\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-behaviors\u0022\u003EAvailable Behaviors\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EBehavior\u003C/th\u003E\n\u003Cth\u003EEffect\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EKeep existing session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPreserveCurrent\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EKeep the current chain and revoke other applicable chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ERevokeAll\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERevoke all applicable session chains\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EFor recovery flows, \u003Ccode\u003EPreserveCurrent\u003C/code\u003E is not always meaningful because the user may not have an authenticated current session.\u003C/p\u003E\n\u003Cp\u003EThe current implementation treats this situation using the more restrictive \u003Ccode\u003ERevokeAll\u003C/code\u003E behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-recommendation-1\u0022\u003ESecurity Recommendation\u003C/h3\u003E\n\u003Cp\u003EPreserving the current session after a normal password change can provide a reasonable user experience.\u003C/p\u003E\n\u003Cp\u003ERevoking all sessions after administrative credential changes or account recovery is generally more conservative.\u003C/p\u003E\n\u003Cp\u003EChoose the policy according to the application\u0027s security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-reset-options\u0022\u003E15. Credential Reset Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.ResetCredential\u003C/code\u003E configures credential recovery proof lifetimes and attempt limits.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022defaults\u0022\u003EDefaults\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ETokenValidity\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E30 minutes\u003C/td\u003E\n\u003Ctd\u003EReset token lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeValidity\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E10 minutes\u003C/td\u003E\n\u003Ctd\u003ENumeric reset code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxAttempts\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum permitted recovery attempts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECodeLength\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E6\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENumeric reset code length\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-3\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.ResetCredential.TokenValidity = TimeSpan.FromMinutes(20);\n options.ResetCredential.CodeValidity = TimeSpan.FromMinutes(5);\n options.ResetCredential.MaxAttempts = 3;\n options.ResetCredential.CodeLength = 6;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003ECodeLength\u003C/code\u003E applies to numeric reset codes, not token-based recovery proofs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-security-boundary\u0022\u003EImportant Security Boundary\u003C/h3\u003E\n\u003Cp\u003EReset proofs must be delivered through a trusted server-side mechanism.\u003C/p\u003E\n\u003Cp\u003EDo not return recovery tokens to anonymous callers simply to simplify a client-side reset form.\u003C/p\u003E\n\u003Cp\u003EThe application should provide the appropriate \u003Ccode\u003EICredentialResetNotifier\u003C/code\u003E implementation for supported recovery flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-identifier-options\u0022\u003E16. Login Identifier Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.LoginIdentifiers\u003C/code\u003E determines which identifier types can be used for login.\u003C/p\u003E\n\u003Cp\u003EThe default allowed types are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EUserIdentifierType.Username\nUserIdentifierType.Email\nUserIdentifierType.Phone\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-login-identifier-types\u0022\u003ERestrict Login Identifier Types\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.LoginIdentifiers.AllowedTypes =\n new HashSet\u0026lt;UserIdentifierType\u0026gt;\n {\n UserIdentifierType.Email\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis configures email as the allowed login identifier type.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022require-verification\u0022\u003ERequire Verification\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.LoginIdentifiers.RequireVerificationForEmail = true;\n options.LoginIdentifiers.RequireVerificationForPhone = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings control whether the corresponding identifiers must be verified before being accepted for login.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-resolvers\u0022\u003ECustom Resolvers\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableCustomResolvers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECustomResolversFirst\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese options control the participation and ordering of custom login identifier resolvers.\u003C/p\u003E\n\u003Cp\u003EAn identifier existing on a user account does not necessarily mean it is permitted for login.\u003C/p\u003E\n\u003Cp\u003EIdentifier management and login eligibility are separate concerns.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-validation\u0022\u003E17. Identifier Validation\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.IdentifierValidation\u003C/code\u003E provides rules for usernames, email addresses, and phone numbers.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022username-rules\u0022\u003EUsername Rules\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.IdentifierValidation.UserName.Enabled = true;\n options.IdentifierValidation.UserName.MinLength = 3;\n options.IdentifierValidation.UserName.MaxLength = 32;\n options.IdentifierValidation.UserName.AllowedRegex = \u0026quot;^[a-zA-Z0-9._-]\u002B$\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022default-validation-rules\u0022\u003EDefault Validation Rules\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EIdentifier\u003C/th\u003E\n\u003Cth\u003EEnabled\u003C/th\u003E\n\u003Cth\u003EMinimum length\u003C/th\u003E\n\u003Cth\u003EMaximum length\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUsername\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E64\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EEmail\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E256\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPhone\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E3\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E24\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUsername validation additionally supports \u003Ccode\u003EAllowedRegex\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default username expression is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EREGEX\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-regex\u0022\u003E^[a-zA-Z0-9._-]\u002B$\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-behavior\u0022\u003EIdentifier Behavior\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Identifiers\u003C/code\u003E contains additional groups:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior\noptions.Identifiers.Validation\noptions.Identifiers.Normalization\noptions.Identifiers.Uniqueness\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese configure identifier behavior beyond the top-level validation rules.\u003C/p\u003E\n\u003Cp\u003EIdentifier normalization and uniqueness are particularly important because they affect how duplicate identifiers are detected.\u003C/p\u003E\n\u003Cp\u003EUse the provided options rather than implementing inconsistent normalization independently in the client.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-limits\u0022\u003EIdentifier Limits\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth supports configurable limits on the number of identifiers associated with each user.\u003C/p\u003E\n\u003Cp\u003EBy default, a user can have up to \u003Cstrong\u003E10 active identifiers\u003C/strong\u003E, regardless of their type.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior.MaxIdentifiersPerUser = 10;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is a global per-user limit. It applies to the combined number of usernames, email addresses, phone numbers, and custom identifiers.\u003C/p\u003E\n\u003Ch4 id=\u0022optional-per-type-limits\u0022\u003EOptional Per-Type Limits\u003C/h4\u003E\n\u003Cp\u003EApplications can optionally define limits for individual identifier types:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Identifiers.Behavior.MaxIdentifiersPerUser = 10;\n\noptions.Identifiers.Behavior.MaxIdentifiersPerType = new()\n{\n [UserIdentifierType.Username] = 2,\n [UserIdentifierType.Email] = 5,\n [UserIdentifierType.Phone] = 3\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EPer-type limits are not configured by default.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EWhen no per-type limit is specified, only the global limit applies.\u003C/p\u003E\n\u003Cp\u003EFor 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.\u003C/p\u003E\n\u003Ch4 id=\u0022default-behavior\u0022\u003EDefault Behavior\u003C/h4\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOption\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxIdentifiersPerUser\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E10\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMaximum total active identifiers per user\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxIdentifiersPerType\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEmpty dictionary\u003C/td\u003E\n\u003Ctd\u003EOptional limits for individual identifier types\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe following rules apply:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EPrimary and non-primary identifiers count equally.\u003C/li\u003E\n\u003Cli\u003ESoft-deleted identifiers do not count toward active limits.\u003C/li\u003E\n\u003Cli\u003ESelf-service and administrative operations follow the same limits.\u003C/li\u003E\n\u003Cli\u003EPer-type limits do not override the global limit.\u003C/li\u003E\n\u003Cli\u003EExisting identifier behavior options remain applicable.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EAllowMultipleUsernames = false\u003C/code\u003E still prevents multiple usernames, even when the configured username limit is greater than one.\u003C/p\u003E\n\u003Ch4 id=\u0022limit-validation\u0022\u003ELimit Validation\u003C/h4\u003E\n\u003Cp\u003EWhen an identifier addition exceeds a configured limit, the operation is rejected with a validation error.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EError Code\u003C/th\u003E\n\u003Cth\u003EMeaning\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eidentifier_total_limit_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe user has reached the global identifier limit\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003Eidentifier_type_limit_exceeded\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EThe user has reached the configured limit for that identifier type\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EApplications should handle these errors and display appropriate feedback.\u003C/p\u003E\n\u003Cp\u003EIdentifier limits are enforced by the Users application layer rather than individual persistence stores.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-profile-options\u0022\u003E18. User Profile Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.UserProfile\u003C/code\u003E controls profile-related capabilities.\u003C/p\u003E\n\u003Cp\u003EThe current option is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.UserProfile.EnableMultiProfile\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIts default is \u003Ccode\u003Efalse\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enable-multiple-profiles\u0022\u003EEnable Multiple Profiles\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.UserProfile.EnableMultiProfile = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis allows applications to use UltimateAuth\u0027s multiple-profile capability where supported by the user domain.\u003C/p\u003E\n\u003Cp\u003EFor example, an application may maintain separate profile contexts for different purposes.\u003C/p\u003E\n\u003Cp\u003EMulti-profile support does not create separate user identities or independent authentication accounts.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022multi-tenant-options\u0022\u003E19. Multi-Tenant Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.MultiTenant\u003C/code\u003E controls whether tenant-aware behavior is enabled and how tenant identifiers are resolved.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-4\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003EDescription\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnabled\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEnable multi-tenant behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENormalizeToLowercase\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ENormalize tenant identifiers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableRoute\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from routes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableHeader\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from headers\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableDomain\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Efalse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EResolve tenants from hosts\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHeaderName\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EX-Tenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant header name\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-header-based-tenant-resolution\u0022\u003EExample: Header-Based Tenant Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.Enabled = true;\n options.MultiTenant.EnableHeader = true;\n options.MultiTenant.HeaderName = \u0026quot;X-Tenant\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-domain-based-resolution\u0022\u003EExample: Domain-Based Resolution\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.MultiTenant.Enabled = true;\n options.MultiTenant.EnableDomain = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EMultiple resolution mechanisms can be enabled.\u003C/p\u003E\n\u003Cp\u003EThe server composes the configured tenant resolvers according to its resolution infrastructure.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-boundary\u0022\u003ESecurity Boundary\u003C/h3\u003E\n\u003Cp\u003ETenant identification is not tenant authorization.\u003C/p\u003E\n\u003Cp\u003EA request providing another tenant identifier must not gain access to that tenant without appropriate authorization.\u003C/p\u003E\n\u003Cp\u003EThe server remains responsible for tenant isolation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022pagination-options\u0022\u003E20. Pagination Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Pagination\u003C/code\u003E controls pagination defaults and maximum page sizes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDefaultPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E250\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMaxPageSize\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003E1000\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-4\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Pagination.DefaultPageSize = 50;\n options.Pagination.MaxPageSize = 200;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings affect paginated queries exposed by UltimateAuth domain services.\u003C/p\u003E\n\u003Cp\u003EFor administrative interfaces, choosing a moderate page size can reduce unnecessary data transfer and processing.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022navigation-options\u0022\u003E21. Navigation Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Navigation\u003C/code\u003E configures automatic authentication navigation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-properties-5\u0022\u003EAvailable Properties\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EProperty\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEnableAutomaticNavigationRedirect\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Etrue\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAccessDeniedResolver\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Enull\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022disable-automatic-redirects\u0022\u003EDisable Automatic Redirects\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.EnableAutomaticNavigationRedirect = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-login-redirect\u0022\u003ECustom Login Redirect\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.LoginResolver = context =\u0026gt;\n {\n return \u0026quot;/account/login\u0026quot;;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-access-denied-redirect\u0022\u003ECustom Access Denied Redirect\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Navigation.AccessDeniedResolver = context =\u0026gt;\n {\n return \u0026quot;/account/access-denied\u0026quot;;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese delegates receive the current \u003Ccode\u003EHttpContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThe application should return valid, trusted navigation destinations.\u003C/p\u003E\n\u003Cp\u003EAvoid constructing redirects from unvalidated user-controlled URLs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-response-options\u0022\u003E22. Authentication Response Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.AuthResponse\u003C/code\u003E controls how authentication credentials and redirect behavior are represented in responses.\u003C/p\u003E\n\u003Cp\u003EThe available groups are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AuthResponse.SessionIdDelivery\noptions.AuthResponse.AccessTokenDelivery\noptions.AuthResponse.RefreshTokenDelivery\n\noptions.AuthResponse.Login\noptions.AuthResponse.Logout\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese settings govern credential delivery and authentication response behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022important-mode-specific-response-defaults\u0022\u003EImportant: Mode-Specific Response Defaults\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth provides response templates for authentication modes.\u003C/p\u003E\n\u003Cp\u003EFor example, \u003Ccode\u003EPureOpaque\u003C/code\u003E uses a session-oriented cookie response, while \u003Ccode\u003EHybrid\u003C/code\u003E combines session and token delivery behavior.\u003C/p\u003E\n\u003Cp\u003EThe effective response may therefore depend on the resolved authentication mode and flow.\u003C/p\u003E\n\u003Cp\u003EDo not assume that a single globally configured response delivery setting applies unchanged to every authentication mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022security-considerations\u0022\u003ESecurity Considerations\u003C/h3\u003E\n\u003Cp\u003ECredential delivery settings determine whether sensitive authentication material is exposed through cookies, headers, or other response mechanisms.\u003C/p\u003E\n\u003Cp\u003EOnly customize them when the application has a clear requirement and the selected transport is appropriate for the client platform.\u003C/p\u003E\n\u003Cp\u003EFor advanced response customization, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022diagnostics-options\u0022\u003E23. Diagnostics Options\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Diagnostics\u003C/code\u003E provides optional diagnostic behavior.\u003C/p\u003E\n\u003Cp\u003EThe current property is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Diagnostics.EnableRefreshDetails\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EIts default is \u003Ccode\u003Efalse\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-5\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Diagnostics.EnableRefreshDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EWhen enabled, the option allows additional refresh-related diagnostic response information, such as the \u003Ccode\u003EX-UAuth-Refresh\u003C/code\u003E header.\u003C/p\u003E\n\u003Cp\u003EThis is primarily useful during development and debugging.\u003C/p\u003E\n\u003Cp\u003EAvoid enabling detailed authentication diagnostics in production unless the exposure is understood and justified.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-lifecycle-events\u0022\u003E24. Authentication Lifecycle Events\u003C/h2\u003E\n\u003Cp\u003E\u003Ccode\u003Eoptions.Events\u003C/code\u003E exposes server-side authentication lifecycle callbacks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022available-events\u0022\u003EAvailable Events\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.Events.OnAnyEvent\noptions.Events.OnUserLoggedIn\noptions.Events.OnUserLoggedOut\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-login-logging\u0022\u003EExample: Login Logging\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Events.OnUserLoggedIn = context =\u0026gt;\n {\n // Record the successful login.\n return Task.CompletedTask;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-global-event-handler\u0022\u003EExample: Global Event Handler\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Events.OnAnyEvent = context =\u0026gt;\n {\n // Process an authentication lifecycle event.\n return Task.CompletedTask;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ELifecycle events are intended for lightweight reactions such as logging, metrics, and notifications.\u003C/p\u003E\n\u003Cp\u003EThey should not replace security decisions, authentication validation, or application policies.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E for event and extension guidance.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022effective-server-options\u0022\u003E25. Effective Server Options\u003C/h2\u003E\n\u003Cp\u003EThe configured \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E instance is the base configuration.\u003C/p\u003E\n\u003Cp\u003EAt runtime, UltimateAuth can resolve:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003EEffectiveUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current resolution process is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EConfigured UAuthServerOptions\n |\n v\n Resolve auth mode\n |\n v\n Clone base options\n |\n v\n Apply mode defaults\n |\n v\n Apply ConfigureMode overrides\n |\n v\n EffectiveUAuthServerOptions\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis means that the effective settings used for a \u003Ccode\u003EHybrid\u003C/code\u003E client can differ from those used for a \u003Ccode\u003EPureOpaque\u003C/code\u003E client.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-6\u0022\u003EExample\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.FromDays(7);\n\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5);\n });\n\n options.ConfigureMode(UAuthMode.PureOpaque, mode =\u0026gt;\n {\n mode.Session.IdleTimeout = TimeSpan.FromDays(3);\n });\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe original options remain the common starting point.\u003C/p\u003E\n\u003Cp\u003EMode defaults and overrides determine the effective values for the resolved mode.\u003C/p\u003E\n\u003Cp\u003EFor implementation details and inspection of effective options, see \u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-and-configuration-safety\u0022\u003E26. Validation and Configuration Safety\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth Server registers multiple options validators and enables startup validation.\u003C/p\u003E\n\u003Cp\u003EThese validators cover areas including:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin settings.\u003C/li\u003E\n\u003Cli\u003ESession settings.\u003C/li\u003E\n\u003Cli\u003EToken settings.\u003C/li\u003E\n\u003Cli\u003EPKCE.\u003C/li\u003E\n\u003Cli\u003EMulti-tenancy.\u003C/li\u003E\n\u003Cli\u003EIdentifier configuration.\u003C/li\u003E\n\u003Cli\u003ESession resolution.\u003C/li\u003E\n\u003Cli\u003EPagination.\u003C/li\u003E\n\u003Cli\u003EGeneral server configuration.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-session-lifetime\u0022\u003EExample: Invalid Session Lifetime\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Session.Lifetime = TimeSpan.Zero;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server rejects an invalid session lifetime under the applicable validation rules.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022example-invalid-base-path\u0022\u003EExample: Invalid Base Path\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;//auth\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe general server validator rejects paths containing \u003Ccode\u003E//\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022unsupported-modes\u0022\u003EUnsupported Modes\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureJwt\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current validator rejects explicitly enabling \u003Ccode\u003EPureJwt\u003C/code\u003E because that mode is not yet implemented as a supported server mode.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation-limitations\u0022\u003EValidation Limitations\u003C/h3\u003E\n\u003Cp\u003EStartup validation protects the configured options according to the registered validators.\u003C/p\u003E\n\u003Cp\u003EIt does not guarantee that every custom service implementation or mode-specific override is secure.\u003C/p\u003E\n\u003Cp\u003EWhen using advanced customization, test the resulting effective configuration and authentication flows.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022complete-configuration-example\u0022\u003E27. Complete Configuration Example\u003C/h2\u003E\n\u003Cp\u003EThe following example combines several commonly used server settings.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n // Authentication modes\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n\n // Login security\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Login.IncludeFailureDetails = false;\n\n // Sessions\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Session.MaxLifetime = TimeSpan.FromDays(30);\n\n // Token identity\n options.Token.Issuer = \u0026quot;MyApplication\u0026quot;;\n options.Token.Audience = \u0026quot;MyApplicationApi\u0026quot;;\n\n // Hybrid-specific token lifetime\n options.ConfigureMode(UAuthMode.Hybrid, mode =\u0026gt;\n {\n mode.Token.AccessTokenLifetime = TimeSpan.FromMinutes(10);\n });\n\n // PKCE\n options.Pkce.AuthorizationCodeLifetimeSeconds = 120;\n\n // Hub deployment\n options.HubDeploymentMode = UAuthHubDeploymentMode.Integrated;\n options.Hub.ClientBaseAddress = \u0026quot;https://app.example.com\u0026quot;;\n options.Hub.AllowedClientOrigins.Add(\u0026quot;https://app.example.com\u0026quot;);\n\n // Credential security\n options.CredentialSessionPolicy.OnSelfChange = CredentialSessionBehavior.PreserveCurrent;\n options.CredentialSessionPolicy.OnAdminChange = CredentialSessionBehavior.RevokeAll;\n options.CredentialSessionPolicy.OnRecovery = CredentialSessionBehavior.RevokeAll;\n\n // Credential recovery\n options.ResetCredential.TokenValidity = TimeSpan.FromMinutes(30);\n options.ResetCredential.MaxAttempts = 3;\n\n // Identifier login rules\n options.LoginIdentifiers.RequireVerificationForEmail = true;\n\n // Session transport\n options.SessionResolution.EnableCookie = true;\n options.SessionResolution.EnableBearer = true;\n options.SessionResolution.EnableQuery = false;\n\n // Endpoint exposure\n options.Endpoints.Authentication = true;\n options.Endpoints.Session = true;\n options.Endpoints.Credentials = true;\n options.Endpoints.Authorization = true;\n\n // Pagination\n options.Pagination.DefaultPageSize = 50;\n options.Pagination.MaxPageSize = 200;\n\n // Diagnostics\n options.Diagnostics.EnableRefreshDetails = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis is an illustrative configuration, not a universal production preset.\u003C/p\u003E\n\u003Cp\u003EApplications should choose session lifetimes, authentication modes, credential transport, tenant behavior, and endpoint exposure according to their own deployment and security requirements.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022common-configuration-mistakes\u0022\u003E28. Common Configuration Mistakes\u003C/h2\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuring-core-options-separately\u0022\u003EConfiguring Core Options Separately\u003C/h3\u003E\n\u003Cp\u003EWhen using \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E, configure Core-defined options through \u003Ccode\u003EUAuthServerOptions\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate \u003Ccode\u003EUAuthOptions\u003C/code\u003E configuration does not automatically override the server\u0027s composed options.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-allowedmodes-selects-the-mode\u0022\u003EAssuming AllowedModes Selects the Mode\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a restriction.\u003C/p\u003E\n\u003Cp\u003EMode selection is performed by the authentication mode resolver.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ignoring-mode-defaults\u0022\u003EIgnoring Mode Defaults\u003C/h3\u003E\n\u003Cp\u003EMode-specific defaults can replace globally configured values.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EConfigureMode()\u003C/code\u003E for explicit mode-specific behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022enabling-unsupported-modes\u0022\u003EEnabling Unsupported Modes\u003C/h3\u003E\n\u003Cp\u003EAn authentication mode appearing in an enum does not guarantee runtime support.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022treating-reserved-session-options-as-enforced-security-features\u0022\u003ETreating Reserved Session Options as Enforced Security Features\u003C/h3\u003E\n\u003Cp\u003ESome device and session-chain options are reserved for future enforcement.\u003C/p\u003E\n\u003Cp\u003EVerify actual runtime support before relying on them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022exposing-unnecessary-endpoints\u0022\u003EExposing Unnecessary Endpoints\u003C/h3\u003E\n\u003Cp\u003EDisable endpoint groups or actions that the application does not require.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022allowing-query-string-credentials-without-a-specific-need\u0022\u003EAllowing Query-String Credentials Without a Specific Need\u003C/h3\u003E\n\u003Cp\u003EQuery-string credentials can leak through URLs and infrastructure logs.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022weakening-cookie-security\u0022\u003EWeakening Cookie Security\u003C/h3\u003E\n\u003Cp\u003EDo not relax secure transport or cookie protections without understanding the consequences.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022assuming-configuration-validation-covers-everything\u0022\u003EAssuming Configuration Validation Covers Everything\u003C/h3\u003E\n\u003Cp\u003EStartup validation does not replace testing, infrastructure security, or validation of custom extension implementations.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-quick-reference\u0022\u003E29. Server Options Quick Reference\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EConfiguration group\u003C/th\u003E\n\u003Cth\u003ETypical use\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELogin\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin attempts and lockout\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESession\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession expiration and lifecycle\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EToken\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EToken issuance and lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPkce\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPKCE code lifetime\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EPermitted authentication modes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EMultiTenant\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ETenant resolution\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPagination\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EQuery page limits\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EEndpoint exposure\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ESessionResolution\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential source precedence\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECookie\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECookie security and scope\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHubDeploymentMode\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub deployment topology\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EHub\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EHub integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EPrimaryCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EUI/API primary credential selection\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EAuthResponse\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential delivery\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ECredentialSessionPolicy\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ESession invalidation after credential changes\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EResetCredential\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ECredential recovery rules\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier behavior\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EIdentifierValidation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EIdentifier validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ELoginIdentifiers\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ELogin identifier eligibility\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EUserProfile\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMulti-profile capability\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003ENavigation\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication redirects\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EDiagnostics\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003ERefresh diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EEvents\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAuthentication lifecycle callbacks\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EConfigureMode()\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EMode-specific option overrides\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003E\u003Ccode\u003EOnConfigureEndpoints\u003C/code\u003E\u003C/td\u003E\n\u003Ctd\u003EAdvanced endpoint customization\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/configuration\u0022\u003EConfiguration Overview\u003C/a\u003E \u2014 Understand the configuration architecture and precedence.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client SDK behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, mode resolution, and extension points.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/session-management\u0022\u003ESession Management\u003C/a\u003E \u2014 Manage sessions through the client.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/client/authentication\u0022\u003EAuthentication\u003C/a\u003E \u2014 Use the client authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "register-ultimateauth-server", @@ -544,6 +544,6 @@ "Level": 0 } ], - "SourceHash": "F71BFB8BC585AF45F096355BDE4989040EEE7545BB8A3B476585E7B77731C19D", + "SourceHash": "01AEC87897DF9AAC89F96EEC5B097505FB71A6F6140A7706A099932C15092C36", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json index ab0acd2b..fde9e879 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/docs-index.json @@ -272,6 +272,13 @@ "Group": "security", "GroupOrder": 8 }, + { + "Title": "Rate Limiting", + "Slug": "security/rate-limiting", + "Order": 8, + "Group": "security", + "GroupOrder": 8 + }, { "Title": "How-to Guides", "Slug": "how-to-guides/index", diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json index 65894848..17e02bae 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json @@ -1,7 +1,7 @@ { "Slug": "getting-started/real-world-setup", "Title": "Real World Setup", - "Html": "\n\u003Cp\u003E\u003Cstrong\u003EOne authentication framework. Different deployment models.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe \u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E demonstrates UltimateAuth with Blazor Server and in-memory storage.\u003C/p\u003E\n\u003Cp\u003EReal applications may require persistent data, a separate authentication server, browser-based clients, or independently deployed APIs.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports these scenarios through different service registrations and hosting integrations. You can choose the architecture that fits your application without configuring every available component.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the setup patterns demonstrated by the UltimateAuth samples.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choose-your-architecture\u0022\u003E1. Choose Your Architecture\u003C/h2\u003E\n\u003Cp\u003EStart by identifying where authentication is hosted and how your application communicates with it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication architecture\u003C/th\u003E\n\u003Cth\u003EAuthentication host\u003C/th\u003E\n\u003Cth\u003EApplication registration\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server, single project\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server with EF Core\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B EF Core \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDedicated UAuthHub\u003C/td\u003E\n\u003Ctd\u003ESeparate authentication application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Hub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone Blazor WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EBlazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Web App, Interactive WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EHost integration \u002B WASM client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeparate Resource API\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EResource API integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese models can be combined.\u003C/p\u003E\n\u003Cp\u003EFor example, an application might use an EF Core-backed UAuthHub, a standalone Blazor WASM frontend, and a separate Resource API.\u003C/p\u003E\n\u003Cp\u003EThe frontend handles user interaction, UAuthHub manages authentication, and the Resource API enforces access to protected resources.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistent-storage-with-entity-framework-core\u0022\u003E2. Persistent Storage with Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor applications that need authentication state to survive restarts, use a persistent storage provider.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides an Entity Framework Core bundle that registers its reference domain implementations and EF Core-backed stores.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\ndotnet add package Microsoft.EntityFrameworkCore.Design\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe SQLite provider is used here for simplicity. You can use another compatible EF Core database provider.\u003C/p\u003E\n\u003Cp\u003EWhen using the EF Core bundle, you do not need the in-memory bundle.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-services\u0022\u003ERegister the services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;, sqlite =\u0026gt; sqlite.MigrationsAssembly(typeof(Program).Assembly.GetName().Name!));\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthEntityFrameworkCore()\u003C/code\u003E registers the EF Core stores and reference domain implementations.\u003C/p\u003E\n\u003Cp\u003EThe bundle provides a unified \u003Ccode\u003EUAuthDbContext\u003C/code\u003E for the supported authentication domains.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register each user, session, credential, token, or authorization store individually.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-database-connection\u0022\u003EConfigure the database connection\u003C/h3\u003E\n\u003Cp\u003EFor a deployed application, prefer configuration over hardcoded connection strings.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003Eappsettings.json\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;ConnectionStrings\u0026quot;: {\n \u0026quot;UltimateAuth\u0026quot;: \u0026quot;Data Source=uauth.db\u0026quot;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar connectionString = builder.Configuration.GetConnectionString(\u0026quot;UltimateAuth\u0026quot;)\n ?? throw new InvalidOperationException(\u0026quot;UltimateAuth connection string is missing.\u0026quot;);\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(connectionString);\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor production databases, use your deployment environment\u0027s secret management facilities rather than storing credentials in source control.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022database-migrations\u0022\u003EDatabase migrations\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth does not automatically create or apply migrations when the EF Core bundle is registered.\u003C/p\u003E\n\u003Cp\u003EYour application owns the database migration lifecycle.\u003C/p\u003E\n\u003Cp\u003EInstall the EF Core CLI if necessary:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet tool install --global dotnet-ef\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECreate the initial migration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EApply it:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef database update --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor Visual Studio Package Manager Console:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EPOWERSHELL\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-powershell\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInstall \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E if required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022applying-migrations-programmatically\u0022\u003EApplying migrations programmatically\u003C/h3\u003E\n\u003Cp\u003EThe EF Core samples demonstrate initialization through a scoped \u003Ccode\u003EUAuthDbContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor a controlled development environment:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nusing (var scope = app.Services.CreateScope())\n{\n var db = scope.ServiceProvider.GetRequiredService\u0026lt;UAuthDbContext\u0026gt;();\n await db.Database.MigrateAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis applies pending migrations. It does not generate migration files.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EProduction guidance:\u003C/strong\u003E Prefer applying reviewed migrations as a separate deployment step, particularly when multiple application instances share a database.\u003C/p\u003E\n\u003Cp\u003EDo not copy the sample initializer\u0027s \u003Ccode\u003Ereset: true\u003C/code\u003E behavior into production. The samples use database reset functionality for repeatable demonstrations, not as a safe deployment strategy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022optional-sample-accounts-with-ef-core\u0022\u003EOptional: sample accounts with EF Core\u003C/h3\u003E\n\u003Cp\u003EThe sample seed package can also work with EF Core persistence.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Sample.Seed\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the scoped seed services:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScopedUltimateAuthSampleSeed();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter preparing the database:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (app.Environment.IsDevelopment())\n{\n await app.SeedUltimateAuthAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe difference matters:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddUltimateAuthSampleSeed()\u003C/code\u003E is used by the in-memory samples.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAddScopedUltimateAuthSampleSeed()\u003C/code\u003E is used by the EF Core samples.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EDo not deploy the demonstration accounts and passwords to production.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-integrated-authentication\u0022\u003E3. Blazor Server \u2014 Integrated Authentication\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Server application that wants to host its own authentication system.\u003C/p\u003E\n\u003Cp\u003EThis is the simplest deployment model for a server-side Blazor application.\u003C/p\u003E\n\u003Cp\u003EThe application hosts UltimateAuth Server and uses the Blazor client adapter in the same project.\u003C/p\u003E\n\u003Cp\u003EA separate UAuthHub is not required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022packages\u0022\u003EPackages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022services\u0022\u003EServices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveServerComponents();\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pipeline\u0022\u003EPipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\napp.UseStaticFiles();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAlso configure the \u003Ccode\u003EUAuthApp\u003C/code\u003E root component, login page, and UltimateAuth JavaScript file as demonstrated in QuickStart.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-behavior\u0022\u003EAuthentication behavior\u003C/h3\u003E\n\u003Cp\u003EThe default client profile resolution selects \u003Ccode\u003EPureOpaque\u003C/code\u003E for Blazor Server.\u003C/p\u003E\n\u003Cp\u003EThis uses session-oriented authentication rather than requiring the application to manage access and refresh tokens manually.\u003C/p\u003E\n\u003Cp\u003EYou can configure session lifetimes, login policies, credential policies, and other behavior through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective authentication mode is resolved at runtime. The application does not need to select a single mode globally.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022uauthhub-dedicated-authentication-server\u0022\u003E4. UAuthHub \u2014 Dedicated Authentication Server\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E Applications where multiple clients share one authentication authority.\u003C/p\u003E\n\u003Cp\u003EUAuthHub hosts UltimateAuth\u0027s authentication services separately from your frontend applications.\u003C/p\u003E\n\u003Cp\u003EIt is particularly relevant for standalone WASM applications, Interactive WASM applications, and systems with separate Resource APIs.\u003C/p\u003E\n\u003Cp\u003EA typical architecture looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Standalone WASM Interactive WASM\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UAuthHub\n Authentication\n Sessions \u0026amp; Users\n \u2502\n \u25BC\n Persistence\n\n Resource API \u2500\u2500\u2500\u25BA UAuthHub\n (validation)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-the-hub-application\u0022\u003ECreate the Hub application\u003C/h3\u003E\n\u003Cp\u003EA UAuthHub can be hosted in an ASP.NET Core Blazor Server application.\u003C/p\u003E\n\u003Cp\u003EThe current samples use Blazor Server to provide the Hub\u0027s login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022in-memory-uauthhub\u0022\u003EIn-memory UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor development:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.InMemory.Bundle\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.InMemory;\nusing CodeBeam.UltimateAuth.Server.Extensions;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub\u0027s Blazor client registration is needed when the Hub itself hosts UltimateAuth-powered Blazor pages and login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ef-core-uauthhub\u0022\u003EEF Core UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor persistent storage:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauthhub.db\u0026quot;);\n })\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E collection identifies browser origins permitted to interact with the Hub.\u003C/p\u003E\n\u003Cp\u003EUse exact origins, including scheme and port. Do not include paths such as \u003Ccode\u003E/home\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-pipeline\u0022\u003EHub pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUltimateAuthEndpoints()\u003C/code\u003E maps the authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUAuthHub()\u003C/code\u003E maps the Hub-specific entry endpoint.\u003C/p\u003E\n\u003Cp\u003EBoth registrations are used by the current UAuthHub samples.\u003C/p\u003E\n\u003Cp\u003EThe Hub also needs its login page and Blazor application lifecycle components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-origins\u0022\u003EChoosing origins\u003C/h3\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication\u003C/th\u003E\n\u003Cth\u003EURL\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUAuthHub\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6112\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6130\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInteractive WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6132\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure the Hub\u0027s allowed origins for the actual frontend applications it serves.\u003C/p\u003E\n\u003Cp\u003EThese localhost addresses are illustrative and must match your deployment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-webassembly\u0022\u003E5. Blazor Standalone WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A browser-hosted application with a separate UAuthHub.\u003C/p\u003E\n\u003Cp\u003EStandalone Blazor WASM runs in the browser. It does not host UltimateAuth Server or database persistence.\u003C/p\u003E\n\u003Cp\u003EAuthentication is handled by the remote UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-packages\u0022\u003EClient packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the standalone WASM application\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.RootComponents.Add\u0026lt;App\u0026gt;(\u0026quot;#app\u0026quot;);\nbuilder.RootComponents.Add\u0026lt;HeadOutlet\u0026gt;(\u0026quot;head::after\u0026quot;);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current standalone WASM sample explicitly configures \u003Ccode\u003ELogin.AllowCredentialPost\u003C/code\u003E and the PKCE return URL.\u003C/p\u003E\n\u003Cp\u003EThese are client-side settings. The UAuthHub must be configured independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-the-urls-mean\u0022\u003EWhat the URLs mean\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E points to the remote authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EPkce.ReturnUrl\u003C/code\u003E identifies the frontend destination used by the relevant PKCE flow.\u003C/p\u003E\n\u003Cp\u003EThe Hub must recognize the frontend\u0027s origin, and any return URL used in an authentication flow must be validated against the application\u0027s security policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022root-component\u0022\u003ERoot component\u003C/h3\u003E\n\u003Cp\u003EA standalone WASM application should initialize UltimateAuth through \u003Ccode\u003EUAuthApp\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, its root application or routing component can contain:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n\n\u0026lt;UAuthApp UseBuiltInRouter=\u0026quot;true\u0026quot; AppAssembly=\u0026quot;typeof(Program).Assembly\u0026quot; DefaultLayout=\u0026quot;typeof(Layout.MainLayout)\u0026quot; /\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInclude the UltimateAuth JavaScript asset in the application\u0027s HTML host page.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003EAuthentication modes\u003C/h3\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EBlazorWasm\u003C/code\u003E profile resolves to \u003Ccode\u003EHybrid\u003C/code\u003E in the current mode resolver.\u003C/p\u003E\n\u003Cp\u003EDo not manually force \u003Ccode\u003EPureOpaque\u003C/code\u003E for a standalone browser client simply because a Blazor Server application uses it.\u003C/p\u003E\n\u003Cp\u003EThe transport and authentication lifecycle must match the client environment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-interactive-webassembly\u0022\u003E6. Blazor Web App \u2014 Interactive WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Web App with an ASP.NET Core host and a separate \u003Ccode\u003E.Client\u003C/code\u003E project.\u003C/p\u003E\n\u003Cp\u003EThis scenario differs from standalone WASM because components may participate in both server-side rendering and browser-side interactivity.\u003C/p\u003E\n\u003Cp\u003EThe current UltimateAuth sample registers the Blazor client integration in \u003Cstrong\u003Eboth projects\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also adds a separate ASP.NET Core compatibility registration in the host.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022project-structure\u0022\u003EProject structure\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EMyApplication/\n\u251C\u2500\u2500 MyApplication/\n\u2502 \u251C\u2500\u2500 Program.cs\n\u2502 \u2514\u2500\u2500 Components/\n\u2502 \u2514\u2500\u2500 App.razor\n\u2502\n\u2514\u2500\u2500 MyApplication.Client/\n \u251C\u2500\u2500 Program.cs\n \u2514\u2500\u2500 Pages/\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host serves the application.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003E.Client\u003C/code\u003E project contains browser-executed components and services.\u003C/p\u003E\n\u003Cp\u003EA remote UAuthHub manages authentication.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-packages\u0022\u003E6.1 Host project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client integration and ASP.NET Core compatibility package in the host:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.Client.AspNetCore\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-services\u0022\u003E6.2 Host project services\u003C/h3\u003E\n\u003Cp\u003EIn the host \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.AspNetCore;\nusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveWebAssemblyComponents();\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nbuilder.Services.AddUltimateAuthAspNetCoreCompatibility();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthAspNetCoreCompatibility()\u003C/code\u003E registers the compatibility authentication scheme and ASP.NET Core authorization services used by this hosting pattern.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E turn the host into an UltimateAuth authentication server.\u003C/p\u003E\n\u003Cp\u003EIt is also not equivalent to \u003Ccode\u003EAddUltimateAuthResourceApi()\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-pipeline\u0022\u003E6.3 Host project pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseAntiforgery();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(typeof(MyApplication.Client._Imports).Assembly, UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EReplace \u003Ccode\u003EMyApplication.Client._Imports\u003C/code\u003E with the corresponding type from your client project.\u003C/p\u003E\n\u003Cp\u003EThe additional assemblies allow the host to discover routable components from the WASM client project and UltimateAuth\u0027s Blazor client assembly.\u003C/p\u003E\n\u003Cp\u003EThe sample does not register \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E in this host because authentication is delegated to a separate UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-packages\u0022\u003E6.4 Client project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client adapter in the \u003Ccode\u003E.Client\u003C/code\u003E project:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-services\u0022\u003E6.5 Client project services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EMyApplication.Client/Program.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host and client configurations should agree on the remote UAuthHub URL and authentication flow settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-both-registrations-are-necessary\u0022\u003EWhy both registrations are necessary\u003C/h3\u003E\n\u003Cp\u003EThe host and browser client have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003ERegistering \u003Ccode\u003EAddUltimateAuthClientBlazor()\u003C/code\u003E in the host does not automatically register those services inside the WebAssembly runtime.\u003C/p\u003E\n\u003Cp\u003ELikewise, registering the client adapter only in the \u003Ccode\u003E.Client\u003C/code\u003E project does not supply the services required during server-side integration.\u003C/p\u003E\n\u003Cp\u003EThe additional ASP.NET Core compatibility registration addresses the host\u0027s ASP.NET Core authentication and authorization integration requirements.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E This compatibility layer is not a replacement for server-side authorization of your protected business APIs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-separate-protected-backend\u0022\u003E7. Resource API \u2014 Separate Protected Backend\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E An API that needs UltimateAuth authentication validation but does not host the authentication system itself.\u003C/p\u003E\n\u003Cp\u003EA Resource API can be an ASP.NET Core Web API, MVC application, or another supported server-side application.\u003C/p\u003E\n\u003Cp\u003EIt does not need to be a Blazor application.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022architecture\u0022\u003EArchitecture\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Client\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource API\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource API uses the configured UAuthHub for remote authentication and session validation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-the-server-integration-package\u0022\u003EInstall the server integration package\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-1\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the Resource API\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Server.Extensions;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddControllers();\n\nbuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthHubBaseUrl\u003C/code\u003E is the base address of the authentication server.\u003C/p\u003E\n\u003Cp\u003ENotice that this example uses the Hub origin without the \u003Ccode\u003E/auth\u003C/code\u003E suffix, matching the current Resource API samples.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E controls the browser origins permitted by the Resource API\u0027s CORS configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-pipeline\u0022\u003EConfigure the pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar app = builder.Build();\n\napp.UseHttpsRedirection();\n\napp.UseUltimateAuthResourceApiWithAspNetCore();\n\napp.MapControllers();\n\napp.Run();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUseUltimateAuthResourceApiWithAspNetCore()\u003C/code\u003E installs the Resource API integration middleware together with ASP.NET Core authentication and authorization middleware.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register a local UltimateAuth Server or its persistence bundle in the Resource API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protecting-endpoints\u0022\u003EProtecting endpoints\u003C/h3\u003E\n\u003Cp\u003EUse ASP.NET Core authorization attributes for endpoints that require authentication:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing Microsoft.AspNetCore.Authorization;\nusing Microsoft.AspNetCore.Mvc;\n\n[ApiController]\n[Route(\u0026quot;api/products\u0026quot;)]\npublic class ProductsController : ControllerBase\n{\n [HttpGet]\n [Authorize]\n public IActionResult GetProducts()\n {\n return Ok(new[]\n {\n new { Id = 1, Name = \u0026quot;Example product\u0026quot; }\n });\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe authorization attribute is enforced by ASP.NET Core on the server.\u003C/p\u003E\n\u003Cp\u003EFor permission-specific rules, configure and apply the corresponding authorization policies.\u003C/p\u003E\n\u003Cp\u003EDo not rely on Blazor UI authorization checks to protect Resource API endpoints.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cors-configuration\u0022\u003ECORS configuration\u003C/h3\u003E\n\u003Cp\u003EThe current Resource API integration registers CORS from the configured allowed origins.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnly add origins that should be allowed to call your API from a browser.\u003C/p\u003E\n\u003Cp\u003ECORS is a browser access control mechanism, not an authentication or authorization mechanism.\u003C/p\u003E\n\u003Cp\u003EA request must still pass the applicable authentication and authorization checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-vs-uauthhub\u0022\u003EResource API vs UAuthHub\u003C/h3\u003E\n\u003Cp\u003EThese are different responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EUAuthHub\u003C/th\u003E\n\u003Cth\u003EResource API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHosts authentication flows\u003C/td\u003E\n\u003Ctd\u003EHosts application resources\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EManages authentication sessions\u003C/td\u003E\n\u003Ctd\u003EValidates remote authentication context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUses authentication persistence\u003C/td\u003E\n\u003Ctd\u003EDoes not require local authentication stores\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProvides login and related endpoints\u003C/td\u003E\n\u003Ctd\u003EProvides application endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServes authentication clients\u003C/td\u003E\n\u003Ctd\u003EServes authorized API consumers\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse a Resource API when you want independent deployment of business endpoints without duplicating the authentication server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-with-an-ef-core-backed-uauthhub\u0022\u003E8. Resource API with an EF Core-Backed UAuthHub\u003C/h2\u003E\n\u003Cp\u003EThe Resource API does not need to use EF Core just because UAuthHub does.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EInteractive WASM\n \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25BA Resource API\n \u2502\n \u25BC\n UAuthHub\n \u2502\n \u25BC\n EF Core Database\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub stores users, credentials, sessions, and related security state in its database.\u003C/p\u003E\n\u003Cp\u003EThe Resource API communicates with the Hub through the configured remote validation integration.\u003C/p\u003E\n\u003Cp\u003EIts registration remains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis separation allows the API and authentication infrastructure to be deployed, scaled, and maintained independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022calling-a-resource-api-from-blazor\u0022\u003E9. Calling a Resource API from Blazor\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides a unified Resource Client for accessing protected application APIs.\u003C/p\u003E\n\u003Cp\u003EInstead of configuring a separate HTTP client and implementing browser credential handling for ordinary resource requests, applications can use \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis keeps authentication, identity operations, and application resource requests accessible through the same client SDK.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-resource-api\u0022\u003EConfigure the Resource API\u003C/h3\u003E\n\u003Cp\u003EFor a separate Resource API, configure its origin through the Blazor client registration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n options.Resource.Credentials = UAuthResourceCredentialsMode.Auto;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E identifies the authentication server.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EResource.BaseAddress\u003C/code\u003E identifies the application Resource API.\u003C/p\u003E\n\u003Cp\u003EThese addresses can point to different applications.\u003C/p\u003E\n\u003Cp\u003EIf the Resource API shares the application\u0027s origin, \u003Ccode\u003EResource.BaseAddress\u003C/code\u003E can remain \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022call-protected-resources\u0022\u003ECall Protected Resources\u003C/h3\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen retrieve application data:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is not null)\n{\n products = result.Value;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource Client also supports POST, PUT, PATCH, and DELETE operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022application-specific-api-services\u0022\u003EApplication-Specific API Services\u003C/h3\u003E\n\u003Cp\u003EApplications can organize resource operations into small domain-specific services.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\nusing CodeBeam.UltimateAuth.Core.Contracts;\n\npublic sealed class ProductApiService(IUAuthClient client)\n{\n public Task\u0026lt;UAuthResult\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;\u0026gt; GetAllAsync()\n =\u0026gt; client.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\n public Task\u0026lt;UAuthResult\u0026lt;Product\u0026gt;\u0026gt; CreateAsync(Product product)\n =\u0026gt; client.Resource.PostAsync\u0026lt;Product\u0026gt;(\u0026quot;/api/products\u0026quot;,product);\n\n public Task\u0026lt;UAuthResult\u0026gt; DeleteAsync(int id)\n =\u0026gt; client.Resource.DeleteAsync($\u0026quot;/api/products/{id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the application service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ProductApiService\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe service uses UltimateAuth\u0027s transport and result contracts rather than implementing another HTTP abstraction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-webassembly\u0022\u003EInteractive WebAssembly\u003C/h3\u003E\n\u003Cp\u003EIn an Interactive WebAssembly application, configure the UltimateAuth Blazor client in each project where the Resource Client will be used.\u003C/p\u003E\n\u003Cp\u003EThe host and \u003Ccode\u003E.Client\u003C/code\u003E project have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003EA shared application API service may be registered in both projects when components execute in both environments.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client uses browser-based transport, including when invoked from an interactive Blazor Server component.\u003C/p\u003E\n\u003Cp\u003EBrowser interop must be available before making resource requests. Do not initiate browser-dependent resource requests during static prerendering.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022standalone-webassembly\u0022\u003EStandalone WebAssembly\u003C/h3\u003E\n\u003Cp\u003EStandalone Blazor WebAssembly applications use the same Resource Client API.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnce configured, application components can call protected resources through \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate application-specific \u003Ccode\u003EHttpClient\u003C/code\u003E is not required for these requests.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022browser-credentials-and-cors\u0022\u003EBrowser Credentials and CORS\u003C/h3\u003E\n\u003Cp\u003EFor cross-origin requests, the browser and receiving API must agree on credential handling.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client supports \u003Ccode\u003EAuto\u003C/code\u003E, \u003Ccode\u003ESameOrigin\u003C/code\u003E, \u003Ccode\u003EInclude\u003C/code\u003E, and \u003Ccode\u003EOmit\u003C/code\u003E credential policies.\u003C/p\u003E\n\u003Cp\u003EFor cross-origin cookie-based authentication, configure the appropriate credential policy and ensure that the Resource API supports the required CORS and cookie settings.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client does not automatically make an API authenticated merely because the user has signed in through UAuthHub.\u003C/p\u003E\n\u003Cp\u003EThe receiving API must validate the authentication material associated with the configured authentication mode and enforce its authorization policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-and-direct-service-calls\u0022\u003EBlazor Server and Direct Service Calls\u003C/h3\u003E\n\u003Cp\u003EBlazor Server applications can often access application services directly when those services are hosted in the same process.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar products = await productService.GetAllAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThere is no need to introduce an HTTP request solely to use the Resource Client.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E when the application actually needs to communicate with a protected Resource API through the browser.\u003C/p\u003E\n\u003Cp\u003EFor server-to-server requests, use an appropriate server-side HTTP client and authentication mechanism.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022learn-more\u0022\u003ELearn More\u003C/h3\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/resource-api-requests\u0022\u003EResource API Requests\u003C/a\u003E for supported operations, result handling, credential configuration, security considerations, and request cancellation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-and-client-profiles\u0022\u003E10. Authentication Modes and Client Profiles\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates \u003Cstrong\u003Eclient profile detection\u003C/strong\u003E from \u003Cstrong\u003Eauthentication mode enforcement\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default resolver maps client profiles to authentication modes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EWeb Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMAUI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAPI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis mapping describes the resolver\u0027s behavior, not a guarantee that every mode is currently fully supported by the server.\u003C/p\u003E\n\u003Cp\u003EIn particular, \u003Ccode\u003EPureJwt\u003C/code\u003E and \u003Ccode\u003ESemiHybrid\u003C/code\u003E have implementation limitations in the current release.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict allowed modes\u003C/h3\u003E\n\u003Cp\u003EOn the authentication server:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a guardrail.\u003C/p\u003E\n\u003Cp\u003EIt does not select a mode for every request.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved from the client profile and then checked against the server\u0027s allowed-mode policy.\u003C/p\u003E\n\u003Cp\u003EFor most applications, use the default mode resolution unless you have a specific integration requirement.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-for-real-applications\u0022\u003E11. Server Options for Real Applications\u003C/h2\u003E\n\u003Cp\u003EProduction configuration should reflect your application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n options.UserProfile.EnableMultiProfile = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EServer options also compose the Core-defined login, session, token, PKCE, and multi-tenant options.\u003C/p\u003E\n\u003Cp\u003EConfigure those through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EDo not independently configure Core options and assume the server will automatically use them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-files\u0022\u003EConfiguration files\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also supports configuration through \u003Ccode\u003Eappsettings.json\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server options registration binds the \u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E configuration section.\u003C/p\u003E\n\u003Cp\u003ERemember that effective mode defaults and mode-specific overrides are applied after the base options have been configured.\u003C/p\u003E\n\u003Cp\u003EFor detailed configuration behavior, see Server Options and Advanced Configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022development-and-production-differences\u0022\u003E12. Development and Production Differences\u003C/h2\u003E\n\u003Cp\u003EThe samples intentionally prioritize visibility and repeatability.\u003C/p\u003E\n\u003Cp\u003EA production application needs additional safeguards.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EArea\u003C/th\u003E\n\u003Cth\u003ESample or development\u003C/th\u003E\n\u003Cth\u003EProduction\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPersistence\u003C/td\u003E\n\u003Ctd\u003EInMemory or local SQLite\u003C/td\u003E\n\u003Ctd\u003EDurable database\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeed accounts\u003C/td\u003E\n\u003Ctd\u003EExample users and passwords\u003C/td\u003E\n\u003Ctd\u003EReal provisioning\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDatabase initialization\u003C/td\u003E\n\u003Ctd\u003EMay reset sample data\u003C/td\u003E\n\u003Ctd\u003EReviewed migrations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHTTPS\u003C/td\u003E\n\u003Ctd\u003ELocal development certificate\u003C/td\u003E\n\u003Ctd\u003EProper TLS termination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECORS\u003C/td\u003E\n\u003Ctd\u003ELocalhost origins\u003C/td\u003E\n\u003Ctd\u003EExact trusted origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESigning keys\u003C/td\u003E\n\u003Ctd\u003EDevelopment defaults\u003C/td\u003E\n\u003Ctd\u003EManaged, persistent keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDiagnostics\u003C/td\u003E\n\u003Ctd\u003EDetailed debugging\u003C/td\u003E\n\u003Ctd\u003ERestrict sensitive diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredential recovery\u003C/td\u003E\n\u003Ctd\u003EDevelopment notifier\u003C/td\u003E\n\u003Ctd\u003EReal secure notification channel\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIdentifier verification\u003C/td\u003E\n\u003Ctd\u003EDevelopment bypass\u003C/td\u003E\n\u003Ctd\u003EProof-based verification\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESecrets\u003C/td\u003E\n\u003Ctd\u003ELocal settings\u003C/td\u003E\n\u003Ctd\u003ESecure configuration provider\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe sample projects include development-only implementations for credential reset notifications and identifier verification.\u003C/p\u003E\n\u003Cp\u003EThose implementations demonstrate extension points and should not be treated as production verification systems.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential recovery\u003C/h3\u003E\n\u003Cp\u003EIf your application exposes credential recovery, configure a real implementation of the credential reset notifier.\u003C/p\u003E\n\u003Cp\u003ERecovery secrets must be delivered through an appropriate channel and must not be exposed directly through anonymous API responses.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-verification\u0022\u003EIdentifier verification\u003C/h3\u003E\n\u003Cp\u003EIf your application supports verified email addresses, phone numbers, or other identifiers, use an implementation that verifies ownership through suitable proof.\u003C/p\u003E\n\u003Cp\u003EDo not use a development bypass verifier in production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022signing-keys\u0022\u003ESigning keys\u003C/h3\u003E\n\u003Cp\u003EConfigure persistent signing keys for authentication modes that issue JWTs.\u003C/p\u003E\n\u003Cp\u003EDevelopment signing key providers are not a substitute for production key management.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022deployment-checklist\u0022\u003E13. Deployment Checklist\u003C/h2\u003E\n\u003Cp\u003EBefore deploying, verify the following:\u003C/p\u003E\n\u003Cul class=\u0022contains-task-list\u0022\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The selected architecture matches the actual client hosting model.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The authentication server uses appropriate persistent storage.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Database migrations have been reviewed and applied.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Sample users and development-only verification services are disabled.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Production signing keys and secrets are configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E HTTPS is enforced at the appropriate boundary.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E UAuthHub and Resource API origins are explicitly configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication endpoints and application endpoints are protected appropriately.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Client and server endpoint URLs match the deployment.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Session expiration, refresh, logout, and revocation have been tested.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Protected resources enforce server-side authorization.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication state and persistence survive the expected application lifecycle.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-your-architecture\u0022\u003E14. Choosing Your Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EStart with one server and one client. Connect more clients whenever you need them.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth is built around two fundamental responsibilities:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Server\u003C/strong\u003E \u2014 Manages authentication, users, credentials, sessions, authorization, and the security lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Client\u003C/strong\u003E \u2014 Integrates an application with the authentication system using the appropriate client profile and authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EEvery application needs access to an authentication server, but that server does not have to be a separate application. It can be hosted within your existing ASP.NET Core application or deployed independently as UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-application-the-simplest-setup\u0022\u003ESingle Application \u2014 The Simplest Setup\u003C/h3\u003E\n\u003Cp\u003EFor a traditional ASP.NET Core or Blazor Server application, UltimateAuth Server and Client can run together.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Your Application \u2502\n\u2502 \u2502\n\u2502 UltimateAuth Client \u2502\n\u2502 \u2502 \u2502\n\u2502 UltimateAuth Server \u2502\n\u2502 \u2502 \u2502\n\u2502 Database \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis approach is ideal when you want authentication without introducing another service or deployment.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022multiple-applications-one-authentication-server\u0022\u003EMultiple Applications \u2014 One Authentication Server\u003C/h3\u003E\n\u003Cp\u003EAs your system grows, you can connect multiple client applications to the same UltimateAuth Server.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Server Blazor WASM\n \u2502 \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n \u2502 \u2502 \u2502\n Razor Pages MAUI Other .NET Clients\n \u2502 \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UltimateAuth Server\n UAuthHub\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC \u25BC\n Users Sessions Authorization\n \u2502\n \u25BC\n Persistence\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach client can have its own application structure, hosting model, and authentication requirements while sharing the same authentication authority.\u003C/p\u003E\n\u003Cp\u003EFor example, a business platform might use Blazor Server for administration, Blazor WASM for its customer portal, MAUI for mobile access, and Razor Pages for an internal application.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAdding another client does not require replacing the existing clients or creating another UltimateAuth Server.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe same users, credentials, security policies, and session infrastructure can serve multiple applications, subject to the authentication modes and client integrations supported by the installed version.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022distributed-applications-separate-resource-apis\u0022\u003EDistributed Applications \u2014 Separate Resource APIs\u003C/h3\u003E\n\u003Cp\u003EWhen your architecture includes independently deployed backend services, Resource APIs can integrate with the same authentication authority.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Multiple Client Applications\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource APIs\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n Validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResource APIs protect application resources without becoming additional authentication servers.\u003C/p\u003E\n\u003Cp\u003EThis lets you separate business services from authentication infrastructure while maintaining a shared security model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022one-foundation-different-scales\u0022\u003EOne Foundation, Different Scales\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EYour scenario\u003C/th\u003E\n\u003Cth\u003ERecommended approach\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA simple Blazor Server application\u003C/td\u003E\n\u003Ctd\u003EIntegrated Server \u002B Client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA traditional ASP.NET Core application\u003C/td\u003E\n\u003Ctd\u003EServer hosted within the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA standalone Blazor WASM application\u003C/td\u003E\n\u003Ctd\u003EClient \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAn Interactive WASM application\u003C/td\u003E\n\u003Ctd\u003EHost \u002B WASM Client \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple web, desktop, or mobile applications\u003C/td\u003E\n\u003Ctd\u003EMultiple Clients \u002B shared UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple backend services\u003C/td\u003E\n\u003Ctd\u003EShared UAuthHub \u002B Resource APIs\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EUltimateAuth does not require a complex architecture to get started, and it does not restrict you to a single client as your application grows.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EStart with the simplest deployment that meets your needs. Add new client applications, persistent storage, or separate Resource APIs as your requirements evolve.\u003C/p\u003E\n\u003Cp\u003EThe authentication foundation remains consistent.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E \u2014 Build the minimal in-memory application.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/ecosystem\u0022\u003EEcosystem\u003C/a\u003E \u2014 Explore packages and supported integrations.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E \u2014 Configure server authentication and security behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client profiles, endpoints, refresh, and reauthentication.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, dependency injection, and extension points.\u003C/li\u003E\n\u003C/ul\u003E\n", + "Html": "\n\u003Cp\u003E\u003Cstrong\u003EOne authentication framework. Different deployment models.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe \u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E demonstrates UltimateAuth with Blazor Server and in-memory storage.\u003C/p\u003E\n\u003Cp\u003EReal applications may require persistent data, a separate authentication server, browser-based clients, or independently deployed APIs.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth supports these scenarios through different service registrations and hosting integrations. You can choose the architecture that fits your application without configuring every available component.\u003C/p\u003E\n\u003Cp\u003EThis guide covers the setup patterns demonstrated by the UltimateAuth samples.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choose-your-architecture\u0022\u003E1. Choose Your Architecture\u003C/h2\u003E\n\u003Cp\u003EStart by identifying where authentication is hosted and how your application communicates with it.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication architecture\u003C/th\u003E\n\u003Cth\u003EAuthentication host\u003C/th\u003E\n\u003Cth\u003EApplication registration\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server, single project\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server with EF Core\u003C/td\u003E\n\u003Ctd\u003ESame application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B EF Core \u002B Blazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDedicated UAuthHub\u003C/td\u003E\n\u003Ctd\u003ESeparate authentication application\u003C/td\u003E\n\u003Ctd\u003EServer \u002B persistence \u002B Hub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone Blazor WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EBlazor client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Web App, Interactive WASM\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EHost integration \u002B WASM client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeparate Resource API\u003C/td\u003E\n\u003Ctd\u003ERemote UAuthHub\u003C/td\u003E\n\u003Ctd\u003EResource API integration\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese models can be combined.\u003C/p\u003E\n\u003Cp\u003EFor example, an application might use an EF Core-backed UAuthHub, a standalone Blazor WASM frontend, and a separate Resource API.\u003C/p\u003E\n\u003Cp\u003EThe frontend handles user interaction, UAuthHub manages authentication, and the Resource API enforces access to protected resources.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistent-storage-with-entity-framework-core\u0022\u003E2. Persistent Storage with Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor applications that need authentication state to survive restarts, use a persistent storage provider.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth provides an Entity Framework Core bundle that registers its reference domain implementations and EF Core-backed stores.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\ndotnet add package Microsoft.EntityFrameworkCore.Design\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe SQLite provider is used here for simplicity. You can use another compatible EF Core database provider.\u003C/p\u003E\n\u003Cp\u003EWhen using the EF Core bundle, you do not need the in-memory bundle.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022register-the-services\u0022\u003ERegister the services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;, sqlite =\u0026gt; sqlite.MigrationsAssembly(typeof(Program).Assembly.GetName().Name!));\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthEntityFrameworkCore()\u003C/code\u003E registers the EF Core stores and reference domain implementations.\u003C/p\u003E\n\u003Cp\u003EThe bundle provides a unified \u003Ccode\u003EUAuthDbContext\u003C/code\u003E for the supported authentication domains.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register each user, session, credential, token, or authorization store individually.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-database-connection\u0022\u003EConfigure the database connection\u003C/h3\u003E\n\u003Cp\u003EFor a deployed application, prefer configuration over hardcoded connection strings.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003Eappsettings.json\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;ConnectionStrings\u0026quot;: {\n \u0026quot;UltimateAuth\u0026quot;: \u0026quot;Data Source=uauth.db\u0026quot;\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar connectionString = builder.Configuration.GetConnectionString(\u0026quot;UltimateAuth\u0026quot;)\n ?? throw new InvalidOperationException(\u0026quot;UltimateAuth connection string is missing.\u0026quot;);\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(connectionString);\n });\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor production databases, use your deployment environment\u0027s secret management facilities rather than storing credentials in source control.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022database-migrations\u0022\u003EDatabase migrations\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth does not automatically create or apply migrations when the EF Core bundle is registered.\u003C/p\u003E\n\u003Cp\u003EYour application owns the database migration lifecycle.\u003C/p\u003E\n\u003Cp\u003EInstall the EF Core CLI if necessary:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet tool install --global dotnet-ef\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ECreate the initial migration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EApply it:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef database update --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EFor Visual Studio Package Manager Console:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EPOWERSHELL\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-powershell\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInstall \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E if required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022applying-migrations-programmatically\u0022\u003EApplying migrations programmatically\u003C/h3\u003E\n\u003Cp\u003EThe EF Core samples demonstrate initialization through a scoped \u003Ccode\u003EUAuthDbContext\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor a controlled development environment:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nusing (var scope = app.Services.CreateScope())\n{\n var db = scope.ServiceProvider.GetRequiredService\u0026lt;UAuthDbContext\u0026gt;();\n await db.Database.MigrateAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis applies pending migrations. It does not generate migration files.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EProduction guidance:\u003C/strong\u003E Prefer applying reviewed migrations as a separate deployment step, particularly when multiple application instances share a database.\u003C/p\u003E\n\u003Cp\u003EDo not copy the sample initializer\u0027s \u003Ccode\u003Ereset: true\u003C/code\u003E behavior into production. The samples use database reset functionality for repeatable demonstrations, not as a safe deployment strategy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022optional-sample-accounts-with-ef-core\u0022\u003EOptional: sample accounts with EF Core\u003C/h3\u003E\n\u003Cp\u003EThe sample seed package can also work with EF Core persistence.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Sample.Seed\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the scoped seed services:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScopedUltimateAuthSampleSeed();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter preparing the database:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eif (app.Environment.IsDevelopment())\n{\n await app.SeedUltimateAuthAsync();\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe difference matters:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EAddUltimateAuthSampleSeed()\u003C/code\u003E is used by the in-memory samples.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EAddScopedUltimateAuthSampleSeed()\u003C/code\u003E is used by the EF Core samples.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EDo not deploy the demonstration accounts and passwords to production.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-integrated-authentication\u0022\u003E3. Blazor Server \u2014 Integrated Authentication\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Server application that wants to host its own authentication system.\u003C/p\u003E\n\u003Cp\u003EThis is the simplest deployment model for a server-side Blazor application.\u003C/p\u003E\n\u003Cp\u003EThe application hosts UltimateAuth Server and uses the Blazor client adapter in the same project.\u003C/p\u003E\n\u003Cp\u003EA separate UAuthHub is not required.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022packages\u0022\u003EPackages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\ndotnet add package Microsoft.EntityFrameworkCore.Sqlite\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022services\u0022\u003EServices\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing CodeBeam.UltimateAuth.Server.Extensions;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveServerComponents();\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022pipeline\u0022\u003EPipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\napp.UseStaticFiles();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAlso configure the \u003Ccode\u003EUAuthApp\u003C/code\u003E root component, login page, and UltimateAuth JavaScript file as demonstrated in QuickStart.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-behavior\u0022\u003EAuthentication behavior\u003C/h3\u003E\n\u003Cp\u003EThe default client profile resolution selects \u003Ccode\u003EPureOpaque\u003C/code\u003E for Blazor Server.\u003C/p\u003E\n\u003Cp\u003EThis uses session-oriented authentication rather than requiring the application to manage access and refresh tokens manually.\u003C/p\u003E\n\u003Cp\u003EYou can configure session lifetimes, login policies, credential policies, and other behavior through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Session.Lifetime = TimeSpan.FromDays(7);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe effective authentication mode is resolved at runtime. The application does not need to select a single mode globally.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022uauthhub-dedicated-authentication-server\u0022\u003E4. UAuthHub \u2014 Dedicated Authentication Server\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E Applications where multiple clients share one authentication authority.\u003C/p\u003E\n\u003Cp\u003EUAuthHub hosts UltimateAuth\u0027s authentication services separately from your frontend applications.\u003C/p\u003E\n\u003Cp\u003EIt is particularly relevant for standalone WASM applications, Interactive WASM applications, and systems with separate Resource APIs.\u003C/p\u003E\n\u003Cp\u003EA typical architecture looks like this:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Standalone WASM Interactive WASM\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UAuthHub\n Authentication\n Sessions \u0026amp; Users\n \u2502\n \u25BC\n Persistence\n\n Resource API \u2500\u2500\u2500\u25BA UAuthHub\n (validation)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-the-hub-application\u0022\u003ECreate the Hub application\u003C/h3\u003E\n\u003Cp\u003EA UAuthHub can be hosted in an ASP.NET Core Blazor Server application.\u003C/p\u003E\n\u003Cp\u003EThe current samples use Blazor Server to provide the Hub\u0027s login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022in-memory-uauthhub\u0022\u003EIn-memory UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor development:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.InMemory.Bundle\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.InMemory;\nusing CodeBeam.UltimateAuth.Server.Extensions;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub\u0027s Blazor client registration is needed when the Hub itself hosts UltimateAuth-powered Blazor pages and login UI.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022ef-core-uauthhub\u0022\u003EEF Core UAuthHub\u003C/h3\u003E\n\u003Cp\u003EFor persistent storage:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.EntityFrameworkCore;\nusing Microsoft.EntityFrameworkCore;\n\nbuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauthhub.db\u0026quot;);\n })\n .AddUAuthHub(options =\u0026gt;\n {\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n });\n\nbuilder.Services.AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe \u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E collection identifies browser origins permitted to interact with the Hub.\u003C/p\u003E\n\u003Cp\u003EUse exact origins, including scheme and port. Do not include paths such as \u003Ccode\u003E/home\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022hub-pipeline\u0022\u003EHub pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseUltimateAuthWithAspNetCore();\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveServerRenderMode()\n .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUltimateAuthEndpoints()\u003C/code\u003E maps the authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EMapUAuthHub()\u003C/code\u003E maps the Hub-specific entry endpoint.\u003C/p\u003E\n\u003Cp\u003EBoth registrations are used by the current UAuthHub samples.\u003C/p\u003E\n\u003Cp\u003EThe Hub also needs its login page and Blazor application lifecycle components.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-origins\u0022\u003EChoosing origins\u003C/h3\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EApplication\u003C/th\u003E\n\u003Cth\u003EURL\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUAuthHub\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6112\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandalone WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6130\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EInteractive WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003Ehttps://localhost:6132\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure the Hub\u0027s allowed origins for the actual frontend applications it serves.\u003C/p\u003E\n\u003Cp\u003EThese localhost addresses are illustrative and must match your deployment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-webassembly\u0022\u003E5. Blazor Standalone WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A browser-hosted application with a separate UAuthHub.\u003C/p\u003E\n\u003Cp\u003EStandalone Blazor WASM runs in the browser. It does not host UltimateAuth Server or database persistence.\u003C/p\u003E\n\u003Cp\u003EAuthentication is handled by the remote UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-packages\u0022\u003EClient packages\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the standalone WASM application\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.RootComponents.Add\u0026lt;App\u0026gt;(\u0026quot;#app\u0026quot;);\nbuilder.RootComponents.Add\u0026lt;HeadOutlet\u0026gt;(\u0026quot;head::after\u0026quot;);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe current standalone WASM sample explicitly configures \u003Ccode\u003ELogin.AllowCredentialPost\u003C/code\u003E and the PKCE return URL.\u003C/p\u003E\n\u003Cp\u003EThese are client-side settings. The UAuthHub must be configured independently.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022what-the-urls-mean\u0022\u003EWhat the URLs mean\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E points to the remote authentication endpoints.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EPkce.ReturnUrl\u003C/code\u003E identifies the frontend destination used by the relevant PKCE flow.\u003C/p\u003E\n\u003Cp\u003EThe Hub must recognize the frontend\u0027s origin, and any return URL used in an authentication flow must be validated against the application\u0027s security policy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022root-component\u0022\u003ERoot component\u003C/h3\u003E\n\u003Cp\u003EA standalone WASM application should initialize UltimateAuth through \u003Ccode\u003EUAuthApp\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, its root application or routing component can contain:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client.Blazor\n\n\u0026lt;UAuthApp UseBuiltInRouter=\u0026quot;true\u0026quot; AppAssembly=\u0026quot;typeof(Program).Assembly\u0026quot; DefaultLayout=\u0026quot;typeof(Layout.MainLayout)\u0026quot; /\u0026gt;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EInclude the UltimateAuth JavaScript asset in the application\u0027s HTML host page.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes\u0022\u003EAuthentication modes\u003C/h3\u003E\n\u003Cp\u003EThe default \u003Ccode\u003EBlazorWasm\u003C/code\u003E profile resolves to \u003Ccode\u003EHybrid\u003C/code\u003E in the current mode resolver.\u003C/p\u003E\n\u003Cp\u003EDo not manually force \u003Ccode\u003EPureOpaque\u003C/code\u003E for a standalone browser client simply because a Blazor Server application uses it.\u003C/p\u003E\n\u003Cp\u003EThe transport and authentication lifecycle must match the client environment.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-interactive-webassembly\u0022\u003E6. Blazor Web App \u2014 Interactive WebAssembly\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E A Blazor Web App with an ASP.NET Core host and a separate \u003Ccode\u003E.Client\u003C/code\u003E project.\u003C/p\u003E\n\u003Cp\u003EThis scenario differs from standalone WASM because components may participate in both server-side rendering and browser-side interactivity.\u003C/p\u003E\n\u003Cp\u003EThe current UltimateAuth sample registers the Blazor client integration in \u003Cstrong\u003Eboth projects\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EIt also adds a separate ASP.NET Core compatibility registration in the host.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022project-structure\u0022\u003EProject structure\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EMyApplication/\n\u251C\u2500\u2500 MyApplication/\n\u2502 \u251C\u2500\u2500 Program.cs\n\u2502 \u2514\u2500\u2500 Components/\n\u2502 \u2514\u2500\u2500 App.razor\n\u2502\n\u2514\u2500\u2500 MyApplication.Client/\n \u251C\u2500\u2500 Program.cs\n \u2514\u2500\u2500 Pages/\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host serves the application.\u003C/p\u003E\n\u003Cp\u003EThe \u003Ccode\u003E.Client\u003C/code\u003E project contains browser-executed components and services.\u003C/p\u003E\n\u003Cp\u003EA remote UAuthHub manages authentication.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-packages\u0022\u003E6.1 Host project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client integration and ASP.NET Core compatibility package in the host:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\ndotnet add package CodeBeam.UltimateAuth.Client.AspNetCore\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-services\u0022\u003E6.2 Host project services\u003C/h3\u003E\n\u003Cp\u003EIn the host \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.AspNetCore;\nusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nbuilder.Services\n .AddRazorComponents()\n .AddInteractiveWebAssemblyComponents();\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nbuilder.Services.AddUltimateAuthAspNetCoreCompatibility();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAddUltimateAuthAspNetCoreCompatibility()\u003C/code\u003E registers the compatibility authentication scheme and ASP.NET Core authorization services used by this hosting pattern.\u003C/p\u003E\n\u003Cp\u003EIt does \u003Cstrong\u003Enot\u003C/strong\u003E turn the host into an UltimateAuth authentication server.\u003C/p\u003E\n\u003Cp\u003EIt is also not equivalent to \u003Ccode\u003EAddUltimateAuthResourceApi()\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022host-project-pipeline\u0022\u003E6.3 Host project pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseHttpsRedirection();\n\napp.UseAntiforgery();\n\napp.MapStaticAssets();\n\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(typeof(MyApplication.Client._Imports).Assembly, UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EReplace \u003Ccode\u003EMyApplication.Client._Imports\u003C/code\u003E with the corresponding type from your client project.\u003C/p\u003E\n\u003Cp\u003EThe additional assemblies allow the host to discover routable components from the WASM client project and UltimateAuth\u0027s Blazor client assembly.\u003C/p\u003E\n\u003Cp\u003EThe sample does not register \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E in this host because authentication is delegated to a separate UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-packages\u0022\u003E6.4 Client project packages\u003C/h3\u003E\n\u003Cp\u003EInstall the Blazor client adapter in the \u003Ccode\u003E.Client\u003C/code\u003E project:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Client.Blazor\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-project-services\u0022\u003E6.5 Client project services\u003C/h3\u003E\n\u003Cp\u003EIn \u003Ccode\u003EMyApplication.Client/Program.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client.Blazor.Extensions;\nusing CodeBeam.UltimateAuth.Core.Domain;\n\nvar builder = WebAssemblyHostBuilder.CreateDefault(args);\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient\n{\n BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)\n});\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;;\n options.Login.AllowCredentialPost = true;\n options.Reauth.Behavior = ReauthBehavior.RaiseEvent;\n});\n\nawait builder.Build().RunAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe host and client configurations should agree on the remote UAuthHub URL and authentication flow settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022why-both-registrations-are-necessary\u0022\u003EWhy both registrations are necessary\u003C/h3\u003E\n\u003Cp\u003EThe host and browser client have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003ERegistering \u003Ccode\u003EAddUltimateAuthClientBlazor()\u003C/code\u003E in the host does not automatically register those services inside the WebAssembly runtime.\u003C/p\u003E\n\u003Cp\u003ELikewise, registering the client adapter only in the \u003Ccode\u003E.Client\u003C/code\u003E project does not supply the services required during server-side integration.\u003C/p\u003E\n\u003Cp\u003EThe additional ASP.NET Core compatibility registration addresses the host\u0027s ASP.NET Core authentication and authorization integration requirements.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E This compatibility layer is not a replacement for server-side authorization of your protected business APIs.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-separate-protected-backend\u0022\u003E7. Resource API \u2014 Separate Protected Backend\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EBest for:\u003C/strong\u003E An API that needs UltimateAuth authentication validation but does not host the authentication system itself.\u003C/p\u003E\n\u003Cp\u003EA Resource API can be an ASP.NET Core Web API, MVC application, or another supported server-side application.\u003C/p\u003E\n\u003Cp\u003EIt does not need to be a Blazor application.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022architecture\u0022\u003EArchitecture\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Client\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource API\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource API uses the configured UAuthHub for remote authentication and session validation behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-the-server-integration-package\u0022\u003EInstall the server integration package\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EBash\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.Server\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-1\u0022\u003EConfigure services\u003C/h3\u003E\n\u003Cp\u003EIn the Resource API\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Server.Extensions;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services.AddControllers();\n\nbuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthHubBaseUrl\u003C/code\u003E is the base address of the authentication server.\u003C/p\u003E\n\u003Cp\u003ENotice that this example uses the Hub origin without the \u003Ccode\u003E/auth\u003C/code\u003E suffix, matching the current Resource API samples.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedClientOrigins\u003C/code\u003E controls the browser origins permitted by the Resource API\u0027s CORS configuration.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-pipeline\u0022\u003EConfigure the pipeline\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar app = builder.Build();\n\napp.UseHttpsRedirection();\n\napp.UseUltimateAuthResourceApiWithAspNetCore();\n\napp.MapControllers();\n\napp.Run();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EUseUltimateAuthResourceApiWithAspNetCore()\u003C/code\u003E installs the Resource API integration middleware together with ASP.NET Core authentication and authorization middleware.\u003C/p\u003E\n\u003Cp\u003EYou do not need to register a local UltimateAuth Server or its persistence bundle in the Resource API.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022protecting-endpoints\u0022\u003EProtecting endpoints\u003C/h3\u003E\n\u003Cp\u003EUse ASP.NET Core authorization attributes for endpoints that require authentication:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing Microsoft.AspNetCore.Authorization;\nusing Microsoft.AspNetCore.Mvc;\n\n[ApiController]\n[Route(\u0026quot;api/products\u0026quot;)]\npublic class ProductsController : ControllerBase\n{\n [HttpGet]\n [Authorize]\n public IActionResult GetProducts()\n {\n return Ok(new[]\n {\n new { Id = 1, Name = \u0026quot;Example product\u0026quot; }\n });\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe authorization attribute is enforced by ASP.NET Core on the server.\u003C/p\u003E\n\u003Cp\u003EFor permission-specific rules, configure and apply the corresponding authorization policies.\u003C/p\u003E\n\u003Cp\u003EDo not rely on Blazor UI authorization checks to protect Resource API endpoints.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022cors-configuration\u0022\u003ECORS configuration\u003C/h3\u003E\n\u003Cp\u003EThe current Resource API integration registers CORS from the configured allowed origins.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnly add origins that should be allowed to call your API from a browser.\u003C/p\u003E\n\u003Cp\u003ECORS is a browser access control mechanism, not an authentication or authorization mechanism.\u003C/p\u003E\n\u003Cp\u003EA request must still pass the applicable authentication and authorization checks.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-vs-uauthhub\u0022\u003EResource API vs UAuthHub\u003C/h3\u003E\n\u003Cp\u003EThese are different responsibilities.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EUAuthHub\u003C/th\u003E\n\u003Cth\u003EResource API\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHosts authentication flows\u003C/td\u003E\n\u003Ctd\u003EHosts application resources\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EManages authentication sessions\u003C/td\u003E\n\u003Ctd\u003EValidates remote authentication context\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EUses authentication persistence\u003C/td\u003E\n\u003Ctd\u003EDoes not require local authentication stores\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProvides login and related endpoints\u003C/td\u003E\n\u003Ctd\u003EProvides application endpoints\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EServes authentication clients\u003C/td\u003E\n\u003Ctd\u003EServes authorized API consumers\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EUse a Resource API when you want independent deployment of business endpoints without duplicating the authentication server.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resource-api-with-an-ef-core-backed-uauthhub\u0022\u003E8. Resource API with an EF Core-Backed UAuthHub\u003C/h2\u003E\n\u003Cp\u003EThe Resource API does not need to use EF Core just because UAuthHub does.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EInteractive WASM\n \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25BA Resource API\n \u2502\n \u25BC\n UAuthHub\n \u2502\n \u25BC\n EF Core Database\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Hub stores users, credentials, sessions, and related security state in its database.\u003C/p\u003E\n\u003Cp\u003EThe Resource API communicates with the Hub through the configured remote validation integration.\u003C/p\u003E\n\u003Cp\u003EIts registration remains:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(options =\u0026gt;\n{\n options.UAuthHubBaseUrl = \u0026quot;https://localhost:6112\u0026quot;;\n options.AllowedClientOrigins.Add(\u0026quot;https://localhost:6132\u0026quot;);\n options.CorsPolicyName = \u0026quot;resourceApi\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis separation allows the API and authentication infrastructure to be deployed, scaled, and maintained independently.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022calling-a-resource-api-from-blazor\u0022\u003E9. Calling a Resource API from Blazor\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides a unified Resource Client for accessing protected application APIs.\u003C/p\u003E\n\u003Cp\u003EInstead of configuring a separate HTTP client and implementing browser credential handling for ordinary resource requests, applications can use \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EThis keeps authentication, identity operations, and application resource requests accessible through the same client SDK.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-the-resource-api\u0022\u003EConfigure the Resource API\u003C/h3\u003E\n\u003Cp\u003EFor a separate Resource API, configure its origin through the Blazor client registration:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\n\nbuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n options.Resource.Credentials = UAuthResourceCredentialsMode.Auto;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EEndpoints.BasePath\u003C/code\u003E identifies the authentication server.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EResource.BaseAddress\u003C/code\u003E identifies the application Resource API.\u003C/p\u003E\n\u003Cp\u003EThese addresses can point to different applications.\u003C/p\u003E\n\u003Cp\u003EIf the Resource API shares the application\u0027s origin, \u003Ccode\u003EResource.BaseAddress\u003C/code\u003E can remain \u003Ccode\u003Enull\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022call-protected-resources\u0022\u003ECall Protected Resources\u003C/h3\u003E\n\u003Cp\u003EInject \u003Ccode\u003EIUAuthClient\u003C/code\u003E into a Blazor component:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003ERazor\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-razor\u0022\u003E@using CodeBeam.UltimateAuth.Client\n@inject IUAuthClient UAuthClient\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen retrieve application data:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar result = await UAuthClient.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\nif (result.IsSuccess \u0026amp;\u0026amp; result.Value is not null)\n{\n products = result.Value;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe Resource Client also supports POST, PUT, PATCH, and DELETE operations.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022application-specific-api-services\u0022\u003EApplication-Specific API Services\u003C/h3\u003E\n\u003Cp\u003EApplications can organize resource operations into small domain-specific services.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing CodeBeam.UltimateAuth.Client;\nusing CodeBeam.UltimateAuth.Core.Contracts;\n\npublic sealed class ProductApiService(IUAuthClient client)\n{\n public Task\u0026lt;UAuthResult\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;\u0026gt; GetAllAsync()\n =\u0026gt; client.Resource.GetAsync\u0026lt;List\u0026lt;Product\u0026gt;\u0026gt;(\u0026quot;/api/products\u0026quot;);\n\n public Task\u0026lt;UAuthResult\u0026lt;Product\u0026gt;\u0026gt; CreateAsync(Product product)\n =\u0026gt; client.Resource.PostAsync\u0026lt;Product\u0026gt;(\u0026quot;/api/products\u0026quot;,product);\n\n public Task\u0026lt;UAuthResult\u0026gt; DeleteAsync(int id)\n =\u0026gt; client.Resource.DeleteAsync($\u0026quot;/api/products/{id}\u0026quot;);\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ERegister the application service:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddScoped\u0026lt;ProductApiService\u0026gt;();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe service uses UltimateAuth\u0027s transport and result contracts rather than implementing another HTTP abstraction.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022interactive-webassembly\u0022\u003EInteractive WebAssembly\u003C/h3\u003E\n\u003Cp\u003EIn an Interactive WebAssembly application, configure the UltimateAuth Blazor client in each project where the Resource Client will be used.\u003C/p\u003E\n\u003Cp\u003EThe host and \u003Ccode\u003E.Client\u003C/code\u003E project have separate dependency injection containers.\u003C/p\u003E\n\u003Cp\u003EA shared application API service may be registered in both projects when components execute in both environments.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client uses browser-based transport, including when invoked from an interactive Blazor Server component.\u003C/p\u003E\n\u003Cp\u003EBrowser interop must be available before making resource requests. Do not initiate browser-dependent resource requests during static prerendering.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022standalone-webassembly\u0022\u003EStandalone WebAssembly\u003C/h3\u003E\n\u003Cp\u003EStandalone Blazor WebAssembly applications use the same Resource Client API.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(options =\u0026gt;\n{\n options.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;;\n options.Resource.BaseAddress = \u0026quot;https://localhost:6122\u0026quot;;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EOnce configured, application components can call protected resources through \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EA separate application-specific \u003Ccode\u003EHttpClient\u003C/code\u003E is not required for these requests.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022browser-credentials-and-cors\u0022\u003EBrowser Credentials and CORS\u003C/h3\u003E\n\u003Cp\u003EFor cross-origin requests, the browser and receiving API must agree on credential handling.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client supports \u003Ccode\u003EAuto\u003C/code\u003E, \u003Ccode\u003ESameOrigin\u003C/code\u003E, \u003Ccode\u003EInclude\u003C/code\u003E, and \u003Ccode\u003EOmit\u003C/code\u003E credential policies.\u003C/p\u003E\n\u003Cp\u003EFor cross-origin cookie-based authentication, configure the appropriate credential policy and ensure that the Resource API supports the required CORS and cookie settings.\u003C/p\u003E\n\u003Cp\u003EThe Resource Client does not automatically make an API authenticated merely because the user has signed in through UAuthHub.\u003C/p\u003E\n\u003Cp\u003EThe receiving API must validate the authentication material associated with the configured authentication mode and enforce its authorization policies.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-server-and-direct-service-calls\u0022\u003EBlazor Server and Direct Service Calls\u003C/h3\u003E\n\u003Cp\u003EBlazor Server applications can often access application services directly when those services are hosted in the same process.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Evar products = await productService.GetAllAsync();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThere is no need to introduce an HTTP request solely to use the Resource Client.\u003C/p\u003E\n\u003Cp\u003EUse \u003Ccode\u003EIUAuthClient.Resource\u003C/code\u003E when the application actually needs to communicate with a protected Resource API through the browser.\u003C/p\u003E\n\u003Cp\u003EFor server-to-server requests, use an appropriate server-side HTTP client and authentication mechanism.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022learn-more\u0022\u003ELearn More\u003C/h3\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/client-sdk/resource-api-requests\u0022\u003EResource API Requests\u003C/a\u003E for supported operations, result handling, credential configuration, security considerations, and request cancellation.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-modes-and-client-profiles\u0022\u003E10. Authentication Modes and Client Profiles\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth separates \u003Cstrong\u003Eclient profile detection\u003C/strong\u003E from \u003Cstrong\u003Eauthentication mode enforcement\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EThe default resolver maps client profiles to authentication modes.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EClient profile\u003C/th\u003E\n\u003Cth\u003EDefault resolved mode\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureOpaque\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor WASM\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EWeb Server\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMAUI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EHybrid\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAPI\u003C/td\u003E\n\u003Ctd\u003E\u003Ccode\u003EPureJwt\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThis mapping describes the resolver\u0027s behavior, not a guarantee that every mode is currently fully supported by the server.\u003C/p\u003E\n\u003Cp\u003EIn particular, \u003Ccode\u003EPureJwt\u003C/code\u003E and \u003Ccode\u003ESemiHybrid\u003C/code\u003E have implementation limitations in the current release.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022restrict-allowed-modes\u0022\u003ERestrict allowed modes\u003C/h3\u003E\n\u003Cp\u003EOn the authentication server:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.AllowedModes =\n [\n UAuthMode.PureOpaque,\n UAuthMode.Hybrid\n ];\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EAllowedModes\u003C/code\u003E is a guardrail.\u003C/p\u003E\n\u003Cp\u003EIt does not select a mode for every request.\u003C/p\u003E\n\u003Cp\u003EThe effective mode is resolved from the client profile and then checked against the server\u0027s allowed-mode policy.\u003C/p\u003E\n\u003Cp\u003EFor most applications, use the default mode resolution unless you have a specific integration requirement.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022server-options-for-real-applications\u0022\u003E11. Server Options for Real Applications\u003C/h2\u003E\n\u003Cp\u003EProduction configuration should reflect your application\u0027s security requirements.\u003C/p\u003E\n\u003Cp\u003EFor example:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.Login.MaxFailedAttempts = 5;\n options.Login.LockoutDuration = TimeSpan.FromMinutes(15);\n options.Identifiers.Behavior.AllowMultipleUsernames = true;\n options.UserProfile.EnableMultiProfile = false;\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EServer options also compose the Core-defined login, session, token, PKCE, and multi-tenant options.\u003C/p\u003E\n\u003Cp\u003EConfigure those through \u003Ccode\u003EAddUltimateAuthServer()\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EDo not independently configure Core options and assume the server will automatically use them.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-files\u0022\u003EConfiguration files\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth also supports configuration through \u003Ccode\u003Eappsettings.json\u003C/code\u003E.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;UltimateAuth\u0026quot;: {\n \u0026quot;Server\u0026quot;: {\n \u0026quot;Login\u0026quot;: {\n \u0026quot;MaxFailedAttempts\u0026quot;: 5,\n \u0026quot;LockoutDuration\u0026quot;: \u0026quot;00:15:00\u0026quot;\n }\n }\n }\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe server options registration binds the \u003Ccode\u003EUltimateAuth:Server\u003C/code\u003E configuration section.\u003C/p\u003E\n\u003Cp\u003ERemember that effective mode defaults and mode-specific overrides are applied after the base options have been configured.\u003C/p\u003E\n\u003Cp\u003EFor detailed configuration behavior, see Server Options and Advanced Configuration.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-ip-address-reverse-proxy\u0022\u003E12. Client IP Address \u0026amp; Reverse Proxy\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses ASP.NET Core\u0027s resolved client IP address for device information and IP-based rate limiting. It does not directly trust \u003Ccode\u003EX-Forwarded-For\u003C/code\u003E or \u003Ccode\u003ECF-Connecting-IP\u003C/code\u003E headers.\u003C/p\u003E\n\u003Cp\u003EIf your application runs behind Caddy, Nginx, or another reverse proxy, configure ASP.NET Core to trust that proxy.\u003C/p\u003E\n\u003Cp\u003EIn \u003Ccode\u003EProgram.cs\u003C/code\u003E, before \u003Ccode\u003Ebuilder.Build()\u003C/code\u003E:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eusing System.Net;\nusing Microsoft.AspNetCore.HttpOverrides;\n\nbuilder.Services.Configure\u0026lt;ForwardedHeadersOptions\u0026gt;(options =\u0026gt;\n{\n options.ForwardedHeaders =\n ForwardedHeaders.XForwardedFor |\n ForwardedHeaders.XForwardedProto;\n\n // Replace with your actual Caddy or Nginx proxy IP.\n options.KnownProxies.Add(IPAddress.Parse(\u0026quot;172.20.0.5\u0026quot;));\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThen enable the middleware \u003Cstrong\u003Ebefore\u003C/strong\u003E UltimateAuth and HTTPS redirection:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseForwardedHeaders();\n\napp.UseHttpsRedirection();\napp.UseUltimateAuthWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe example IP \u003Ccode\u003E172.20.0.5\u003C/code\u003E represents a trusted reverse proxy, not the visitor\u0027s IP address. Replace it with your actual proxy IP. For Docker deployments, ensure the proxy address is stable or configure a narrowly scoped trusted network.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EImportant:\u003C/strong\u003E Never trust arbitrary forwarded headers or clear the trusted proxy lists to accept all sources. If your application connects directly to Kestrel without a reverse proxy, no forwarded headers configuration is required.\u003C/p\u003E\n\u003Cp\u003EFor multi-proxy deployments, configure the complete trusted proxy chain and forwarding behavior according to your infrastructure.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022development-and-production-differences\u0022\u003E13. Development and Production Differences\u003C/h2\u003E\n\u003Cp\u003EThe samples intentionally prioritize visibility and repeatability.\u003C/p\u003E\n\u003Cp\u003EA production application needs additional safeguards.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EArea\u003C/th\u003E\n\u003Cth\u003ESample or development\u003C/th\u003E\n\u003Cth\u003EProduction\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPersistence\u003C/td\u003E\n\u003Ctd\u003EInMemory or local SQLite\u003C/td\u003E\n\u003Ctd\u003EDurable database\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESeed accounts\u003C/td\u003E\n\u003Ctd\u003EExample users and passwords\u003C/td\u003E\n\u003Ctd\u003EReal provisioning\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDatabase initialization\u003C/td\u003E\n\u003Ctd\u003EMay reset sample data\u003C/td\u003E\n\u003Ctd\u003EReviewed migrations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EHTTPS\u003C/td\u003E\n\u003Ctd\u003ELocal development certificate\u003C/td\u003E\n\u003Ctd\u003EProper TLS termination\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECORS\u003C/td\u003E\n\u003Ctd\u003ELocalhost origins\u003C/td\u003E\n\u003Ctd\u003EExact trusted origins\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESigning keys\u003C/td\u003E\n\u003Ctd\u003EDevelopment defaults\u003C/td\u003E\n\u003Ctd\u003EManaged, persistent keys\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EDiagnostics\u003C/td\u003E\n\u003Ctd\u003EDetailed debugging\u003C/td\u003E\n\u003Ctd\u003ERestrict sensitive diagnostics\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ECredential recovery\u003C/td\u003E\n\u003Ctd\u003EDevelopment notifier\u003C/td\u003E\n\u003Ctd\u003EReal secure notification channel\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EIdentifier verification\u003C/td\u003E\n\u003Ctd\u003EDevelopment bypass\u003C/td\u003E\n\u003Ctd\u003EProof-based verification\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESecrets\u003C/td\u003E\n\u003Ctd\u003ELocal settings\u003C/td\u003E\n\u003Ctd\u003ESecure configuration provider\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThe sample projects include development-only implementations for credential reset notifications and identifier verification.\u003C/p\u003E\n\u003Cp\u003EThose implementations demonstrate extension points and should not be treated as production verification systems.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022credential-recovery\u0022\u003ECredential recovery\u003C/h3\u003E\n\u003Cp\u003EIf your application exposes credential recovery, configure a real implementation of the credential reset notifier.\u003C/p\u003E\n\u003Cp\u003ERecovery secrets must be delivered through an appropriate channel and must not be exposed directly through anonymous API responses.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022identifier-verification\u0022\u003EIdentifier verification\u003C/h3\u003E\n\u003Cp\u003EIf your application supports verified email addresses, phone numbers, or other identifiers, use an implementation that verifies ownership through suitable proof.\u003C/p\u003E\n\u003Cp\u003EDo not use a development bypass verifier in production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022signing-keys\u0022\u003ESigning keys\u003C/h3\u003E\n\u003Cp\u003EConfigure persistent signing keys for authentication modes that issue JWTs.\u003C/p\u003E\n\u003Cp\u003EDevelopment signing key providers are not a substitute for production key management.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022deployment-checklist\u0022\u003E14. Deployment Checklist\u003C/h2\u003E\n\u003Cp\u003EBefore deploying, verify the following:\u003C/p\u003E\n\u003Cul class=\u0022contains-task-list\u0022\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The selected architecture matches the actual client hosting model.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E The authentication server uses appropriate persistent storage.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Database migrations have been reviewed and applied.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Sample users and development-only verification services are disabled.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Production signing keys and secrets are configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E HTTPS is enforced at the appropriate boundary.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E UAuthHub and Resource API origins are explicitly configured.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication endpoints and application endpoints are protected appropriately.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Client and server endpoint URLs match the deployment.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Session expiration, refresh, logout, and revocation have been tested.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Protected resources enforce server-side authorization.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Reverse proxy headers are configured correctly if applicable.\u003C/li\u003E\n\u003Cli class=\u0022task-list-item\u0022\u003E\u003Cinput disabled=\u0022disabled\u0022 type=\u0022checkbox\u0022\u003E Authentication state and persistence survive the expected application lifecycle.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022choosing-your-architecture\u0022\u003E15. Choosing Your Architecture\u003C/h2\u003E\n\u003Cp\u003E\u003Cstrong\u003EStart with one server and one client. Connect more clients whenever you need them.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth is built around two fundamental responsibilities:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Server\u003C/strong\u003E \u2014 Manages authentication, users, credentials, sessions, authorization, and the security lifecycle.\u003C/li\u003E\n\u003Cli\u003E\u003Cstrong\u003EUltimateAuth Client\u003C/strong\u003E \u2014 Integrates an application with the authentication system using the appropriate client profile and authentication flows.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EEvery application needs access to an authentication server, but that server does not have to be a separate application. It can be hosted within your existing ASP.NET Core application or deployed independently as UAuthHub.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022single-application-the-simplest-setup\u0022\u003ESingle Application \u2014 The Simplest Setup\u003C/h3\u003E\n\u003Cp\u003EFor a traditional ASP.NET Core or Blazor Server application, UltimateAuth Server and Client can run together.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 Your Application \u2502\n\u2502 \u2502\n\u2502 UltimateAuth Client \u2502\n\u2502 \u2502 \u2502\n\u2502 UltimateAuth Server \u2502\n\u2502 \u2502 \u2502\n\u2502 Database \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis approach is ideal when you want authentication without introducing another service or deployment.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022multiple-applications-one-authentication-server\u0022\u003EMultiple Applications \u2014 One Authentication Server\u003C/h3\u003E\n\u003Cp\u003EAs your system grows, you can connect multiple client applications to the same UltimateAuth Server.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Blazor Server Blazor WASM\n \u2502 \u2502\n \u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n \u2502 \u2502 \u2502\n Razor Pages MAUI Other .NET Clients\n \u2502 \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502\n \u25BC\n UltimateAuth Server\n UAuthHub\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC \u25BC\n Users Sessions Authorization\n \u2502\n \u25BC\n Persistence\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EEach client can have its own application structure, hosting model, and authentication requirements while sharing the same authentication authority.\u003C/p\u003E\n\u003Cp\u003EFor example, a business platform might use Blazor Server for administration, Blazor WASM for its customer portal, MAUI for mobile access, and Razor Pages for an internal application.\u003C/p\u003E\n\u003Cp\u003E\u003Cstrong\u003EAdding another client does not require replacing the existing clients or creating another UltimateAuth Server.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EThe same users, credentials, security policies, and session infrastructure can serve multiple applications, subject to the authentication modes and client integrations supported by the installed version.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022distributed-applications-separate-resource-apis\u0022\u003EDistributed Applications \u2014 Separate Resource APIs\u003C/h3\u003E\n\u003Cp\u003EWhen your architecture includes independently deployed backend services, Resource APIs can integrate with the same authentication authority.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003E Multiple Client Applications\n \u2502\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u25BC \u25BC\n UAuthHub Resource APIs\n \u25B2 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n Authentication\n Validation\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EResource APIs protect application resources without becoming additional authentication servers.\u003C/p\u003E\n\u003Cp\u003EThis lets you separate business services from authentication infrastructure while maintaining a shared security model.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022one-foundation-different-scales\u0022\u003EOne Foundation, Different Scales\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EYour scenario\u003C/th\u003E\n\u003Cth\u003ERecommended approach\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA simple Blazor Server application\u003C/td\u003E\n\u003Ctd\u003EIntegrated Server \u002B Client\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA traditional ASP.NET Core application\u003C/td\u003E\n\u003Ctd\u003EServer hosted within the application\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EA standalone Blazor WASM application\u003C/td\u003E\n\u003Ctd\u003EClient \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAn Interactive WASM application\u003C/td\u003E\n\u003Ctd\u003EHost \u002B WASM Client \u002B remote UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple web, desktop, or mobile applications\u003C/td\u003E\n\u003Ctd\u003EMultiple Clients \u002B shared UAuthHub\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EMultiple backend services\u003C/td\u003E\n\u003Ctd\u003EShared UAuthHub \u002B Resource APIs\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Cstrong\u003EUltimateAuth does not require a complex architecture to get started, and it does not restrict you to a single client as your application grows.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EStart with the simplest deployment that meets your needs. Add new client applications, persistent storage, or separate Resource APIs as your requirements evolve.\u003C/p\u003E\n\u003Cp\u003EThe authentication foundation remains consistent.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022next-steps\u0022\u003ENext Steps\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/quickstart\u0022\u003EQuickStart\u003C/a\u003E \u2014 Build the minimal in-memory application.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/getting-started/ecosystem\u0022\u003EEcosystem\u003C/a\u003E \u2014 Explore packages and supported integrations.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E \u2014 Configure server authentication and security behavior.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E \u2014 Configure client profiles, endpoints, refresh, and reauthentication.\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/advanced-configuration\u0022\u003EAdvanced Configuration\u003C/a\u003E \u2014 Explore effective options, dependency injection, and extension points.\u003C/li\u003E\n\u003C/ul\u003E\n", "Headings": [ { "Id": "choose-your-architecture", @@ -278,9 +278,14 @@ "Text": "Configuration files", "Level": 1 }, + { + "Id": "client-ip-address-reverse-proxy", + "Text": "12. Client IP Address \u0026 Reverse Proxy", + "Level": 0 + }, { "Id": "development-and-production-differences", - "Text": "12. Development and Production Differences", + "Text": "13. Development and Production Differences", "Level": 0 }, { @@ -300,12 +305,12 @@ }, { "Id": "deployment-checklist", - "Text": "13. Deployment Checklist", + "Text": "14. Deployment Checklist", "Level": 0 }, { "Id": "choosing-your-architecture", - "Text": "14. Choosing Your Architecture", + "Text": "15. Choosing Your Architecture", "Level": 0 }, { @@ -334,6 +339,6 @@ "Level": 0 } ], - "SourceHash": "770C2ACEAC642791FB3C618D54F7EB1108DF9651ABFBF28CD7D5700EB25EF255", + "SourceHash": "B1EA20A2729E11F13A9E2D7B12F6D1519AD7A45FE3DBFB4A9D79C01E06D0ED5A", "BuilderVersion": "1" } \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/rate-limiting.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/rate-limiting.json new file mode 100644 index 00000000..6ae687ac --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/security/rate-limiting.json @@ -0,0 +1,154 @@ +{ + "Slug": "security/rate-limiting", + "Title": "Rate Limiting", + "Html": "\n\u003Cp\u003EUltimateAuth provides built-in HTTP endpoint rate limiting using ASP.NET Core\u0027s native rate limiting infrastructure, together with client-side handling of HTTP 429 responses.\u003C/p\u003E\n\u003Cp\u003ERate limiting helps reduce excessive authentication requests, credential attacks, recovery abuse, and unnecessary session traffic without introducing a separate persistence dependency.\u003C/p\u003E\n\u003Cp\u003EThe server enforces request limits. UltimateAuth clients recognize throttled responses, preserve valid authentication state, and defer selected background operations when appropriate.\u003C/p\u003E\n\u003Cp\u003ENo additional rate limiting package is required.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022getting-started\u0022\u003EGetting started\u003C/h2\u003E\n\u003Cp\u003ERate limiting is enabled by default when using the integrated ASP.NET Core middleware helper.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.RateLimiting.Standard.PermitLimit = 240;\n options.RateLimiting.Login.PermitLimit = 40;\n\n options.RateLimiting.Endpoints[UAuthEndpoint.Refresh] = new()\n {\n PermitLimit = 1200,\n Window = TimeSpan.FromMinutes(1)\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EConfigure the middleware pipeline:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseForwardedHeaders();\napp.UseRouting();\n\napp.UseUltimateAuthWithAspNetCore();\n\napp.UseAntiforgery();\n\napp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EImport \u003Ccode\u003ECodeBeam.UltimateAuth.Server.RateLimiting\u003C/code\u003E for rate limiting rules and endpoint identifiers.\u003C/p\u003E\n\u003Cp\u003EConfigure forwarded headers only when appropriate for the deployment, using explicitly trusted proxies and networks.\u003C/p\u003E\n\u003Cp\u003ERate limiting options are also available through the \u003Ccode\u003EUltimateAuth:Server:RateLimiting\u003C/code\u003E configuration section. The existing server options binding behavior applies, including configuration-file precedence.\u003C/p\u003E\n\u003Cp\u003ERate limiting settings are configured at startup. They are not automatically scoped to individual clients or authentication modes.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022middleware-integration\u0022\u003EMiddleware integration\u003C/h2\u003E\n\u003Cp\u003EThe integrated helper exposes a single method with two optional parameters:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Epublic static IApplicationBuilder UseUltimateAuthWithAspNetCore(\n this IApplicationBuilder app,\n bool? enableCors = null,\n bool useRateLimiting = true)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe default middleware order is:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ECORS, when enabled or required by UAuthHub\u003C/li\u003E\n\u003Cli\u003EUltimateAuth context middleware\u003C/li\u003E\n\u003Cli\u003EASP.NET Core authentication\u003C/li\u003E\n\u003Cli\u003EASP.NET Core rate limiting\u003C/li\u003E\n\u003Cli\u003EASP.NET Core authorization\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EThis places the rate limiter after authentication, allowing authenticated endpoint policies to use validated UltimateAuth actor information.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configuration-examples\u0022\u003EConfiguration examples\u003C/h3\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003E// Automatic CORS selection; rate limiting enabled\napp.UseUltimateAuthWithAspNetCore();\n\n// Explicit CORS; rate limiting enabled\napp.UseUltimateAuthWithAspNetCore(enableCors: true);\n\n// Automatic CORS; rate limiting disabled\napp.UseUltimateAuthWithAspNetCore(useRateLimiting: false);\n\n// Explicit configuration of both options\napp.UseUltimateAuthWithAspNetCore(enableCors: true, useRateLimiting: true);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003E\u003Ccode\u003EenableCors = null\u003C/code\u003E automatically enables CORS when required by UAuthHub. Setting it to \u003Ccode\u003Efalse\u003C/code\u003E does not remove the host\u0027s responsibility to configure CORS when needed.\u003C/p\u003E\n\u003Cp\u003EThe standalone UltimateAuth middleware helper does not install ASP.NET Core authentication. If authentication has not already established a validated UltimateAuth actor, rate limiting partitioning falls back to the connection IP.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022existing-application-rate-limiters\u0022\u003EExisting application rate limiters\u003C/h3\u003E\n\u003Cp\u003EIf the application already installs its own \u003Ccode\u003EUseRateLimiter()\u003C/code\u003E middleware, disable the helper-owned installation:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseRouting();\napp.UseAuthentication();\n\napp.UseUltimateAuthWithAspNetCore(useRateLimiting: false);\n\napp.UseRateLimiter();\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThis illustrates middleware ownership rather than a universal pipeline layout. Hosts must preserve the required routing, authentication, CORS, and authorization order and avoid duplicate middleware registration.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth cannot detect a separately installed native rate limiting middleware instance.\u003C/p\u003E\n\u003Cp\u003ERepeated calls to the UltimateAuth helper install at most one helper-owned rate limiter on the same application builder. Other middleware installed by the helper is not deduplicated.\u003C/p\u003E\n\u003Cp\u003EAn application builder created with \u003Ccode\u003EApplicationBuilder.New()\u003C/code\u003E has its own middleware installation lifecycle.\u003C/p\u003E\n\u003Cp\u003EDisabling UltimateAuth\u0027s built-in endpoint policies does not disable an application-owned global limiter or native application policies.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022rules-and-default-limits\u0022\u003ERules and default limits\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth uses native sliding-window limiters with six segments and no request queue.\u003C/p\u003E\n\u003Cp\u003EThe default rule groups are:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ERule\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003Cth\u003ETypical operations\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESensitive\u003C/td\u003E\n\u003Ctd\u003E30 requests/minute\u003C/td\u003E\n\u003Ctd\u003ESensitive credential and recovery operations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EStandard\u003C/td\u003E\n\u003Ctd\u003E120 requests/minute\u003C/td\u003E\n\u003Ctd\u003EAuthenticated management operations\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EFrequent\u003C/td\u003E\n\u003Ctd\u003E600 requests/minute\u003C/td\u003E\n\u003Ctd\u003EValidation, refresh, logout, authorization checks\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogin\u003C/td\u003E\n\u003Ctd\u003E20 requests/minute/IP\u003C/td\u003E\n\u003Ctd\u003EShared credential-verification budget\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese are starting defaults, not recommended universal production limits. Adjust them based on application traffic, expected concurrency, and deployment conditions.\u003C/p\u003E\n\u003Cp\u003EGroup rules act as templates for separate endpoint budgets. For example, validation and refresh do not share a counter merely because both use the Frequent group.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022shared-login-budget\u0022\u003EShared login budget\u003C/h3\u003E\n\u003Cp\u003EThe Login rule is shared by:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ELogin\u003C/li\u003E\n\u003Cli\u003ETryLogin\u003C/li\u003E\n\u003Cli\u003EPkceComplete\u003C/li\u003E\n\u003Cli\u003EPkceTryComplete\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThese credential-verification operations share the same budget for a partition.\u003C/p\u003E\n\u003Cp\u003EConfigure the shared limit through:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.RateLimiting.Login.PermitLimit = 40;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAn explicit endpoint override replaces that endpoint\u0027s rule and separates it from the shared login budget.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022additional-sensitive-defaults\u0022\u003EAdditional sensitive defaults\u003C/h3\u003E\n\u003Cp\u003ECertain operations have stricter defaults than the Sensitive group:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EDefault\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAnonymous registration\u003C/td\u003E\n\u003Ctd\u003E10 requests/10 minutes/IP\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAnonymous credential reset initiation\u003C/td\u003E\n\u003Ctd\u003E5 requests/10 minutes/IP\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAnonymous credential reset completion\u003C/td\u003E\n\u003Ctd\u003E10 requests/10 minutes/IP\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESelf identifier verification\u003C/td\u003E\n\u003Ctd\u003E10 requests/10 minutes/IP\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EThese numeric defaults take precedence over the Sensitive group\u0027s numeric settings.\u003C/p\u003E\n\u003Cp\u003EFor example, increasing \u003Ccode\u003ESensitive.PermitLimit\u003C/code\u003E does not automatically increase the anonymous reset initiation limit.\u003C/p\u003E\n\u003Cp\u003EUse an explicit endpoint override to change these limits.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022per-endpoint-configuration\u0022\u003EPer-endpoint configuration\u003C/h2\u003E\n\u003Cp\u003EIndividual endpoint rules can be configured using \u003Ccode\u003ERateLimiting.Endpoints\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EFor example, limit session validation to two requests per minute:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new()\n {\n PermitLimit = 2,\n Window = TimeSpan.FromMinutes(1)\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe third request within the applicable sliding window receives HTTP 429 when the budget is exhausted.\u003C/p\u003E\n\u003Cp\u003EThis example is useful for testing client behavior, but two validations per minute may be too restrictive for a real application with automatic session validation.\u003C/p\u003E\n\u003Cp\u003EEndpoint overrides are complete replacement rules, not partial changes merged with group numeric settings.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022policy-precedence\u0022\u003EPolicy precedence\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth resolves endpoint policies in the following order:\u003C/p\u003E\n\u003Col\u003E\n\u003Cli\u003ENative application endpoint/group rate limiting metadata, including native disable metadata\u003C/li\u003E\n\u003Cli\u003EExplicit \u003Ccode\u003EEndpoints[id]\u003C/code\u003E replacement\u003C/li\u003E\n\u003Cli\u003EShared Login rule for credential-verification operations\u003C/li\u003E\n\u003Cli\u003ESpecial registration, reset, and verification defaults\u003C/li\u003E\n\u003Cli\u003EEndpoint group template\u003C/li\u003E\n\u003C/ol\u003E\n\u003Cp\u003EAn explicit endpoint override can replace a built-in rule, select an application-owned named policy, or disable the built-in policy for that endpoint.\u003C/p\u003E\n\u003Cp\u003EAll UltimateAuth endpoint mappings have explicit classifications. Endpoint registration fails when a newly mapped endpoint is missing its required classification.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022disabling-rate-limiting\u0022\u003EDisabling rate limiting\u003C/h2\u003E\n\u003Cp\u003ETo disable UltimateAuth\u0027s built-in endpoint policies globally:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.RateLimiting.Enabled = false;\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ETo disable an individual built-in rule:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eoptions.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new()\n{\n Enabled = false\n};\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EA disabled UltimateAuth rule does not add native \u003Ccode\u003EDisableRateLimiting\u003C/code\u003E metadata.\u003C/p\u003E\n\u003Cp\u003EApplication-owned policies and global limiters may still apply.\u003C/p\u003E\n\u003Cp\u003ETo prevent the integrated helper from installing the native middleware:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthWithAspNetCore(useRateLimiting: false);\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThese controls have different responsibilities:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003ERateLimiting.Enabled\u003C/code\u003E controls UltimateAuth\u0027s built-in policies.\u003C/li\u003E\n\u003Cli\u003EIndividual rule \u003Ccode\u003EEnabled\u003C/code\u003E settings control specific built-in rules.\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EuseRateLimiting\u003C/code\u003E controls whether the integrated helper installs the middleware.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-application-owned-policies\u0022\u003EUsing application-owned policies\u003C/h2\u003E\n\u003Cp\u003EApplications can use their own native ASP.NET Core policies instead of an UltimateAuth built-in rule.\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddRateLimiter(options =\u0026gt;\n{\n options.AddPolicy\u0026lt;string, MyRateLimiterPolicy\u0026gt;(\u0026quot;my-auth-policy\u0026quot;);\n});\n\nbuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.RateLimiting.Endpoints[UAuthEndpoint.UserCreateAnonymous] = new()\n {\n PolicyName = \u0026quot;my-auth-policy\u0026quot;\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003ENamed policies must be registered with the native rate limiting options used by the middleware.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth\u0027s built-in policies are inline native policies and do not reserve application policy names.\u003C/p\u003E\n\u003Cp\u003EEndpoint policies do not automatically compose. Native ASP.NET Core endpoint/group precedence applies.\u003C/p\u003E\n\u003Cp\u003ENative \u003Ccode\u003EDisableRateLimiting\u003C/code\u003E metadata also follows ASP.NET Core semantics and can bypass an application global limiter.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022rejection-callbacks\u0022\u003ERejection callbacks\u003C/h3\u003E\n\u003Cp\u003EBuilt-in UltimateAuth inline policies produce their own generic HTTP 429 response.\u003C/p\u003E\n\u003Cp\u003EThey do not invoke the application\u0027s middleware-level \u003Ccode\u003EOnRejected\u003C/code\u003E callback.\u003C/p\u003E\n\u003Cp\u003EApplication-owned named policies can use native rejection behavior. When a named policy has no policy-level rejection callback, the effective native middleware \u003Ccode\u003EOnRejected\u003C/code\u003E callback applies.\u003C/p\u003E\n\u003Cp\u003EIf an application needs complete ownership of rejection responses for a particular endpoint, use an application-owned policy.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth does not replace application global limiters or application rejection settings.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022http-429-responses\u0022\u003EHTTP 429 responses\u003C/h2\u003E\n\u003Cp\u003EWhen a built-in UltimateAuth policy rejects a request, the server returns:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EHTTP status \u003Ccode\u003E429 Too Many Requests\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003EProblem Details content\u003C/li\u003E\n\u003Cli\u003EStable detail code \u003Ccode\u003Erate_limited\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ECache-Control: no-store\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ERetry-After\u003C/code\u003E when the native limiter supplies retry guidance\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EA representative response is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EJSON\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-json\u0022\u003E{\n \u0026quot;title\u0026quot;: \u0026quot;Too Many Requests\u0026quot;,\n \u0026quot;status\u0026quot;: 429,\n \u0026quot;detail\u0026quot;: \u0026quot;rate_limited\u0026quot;\n}\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe exact Problem Details payload may include additional fields.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ERetry-After\u003C/code\u003E is optional. Applications must not assume that every HTTP 429 response contains it.\u003C/p\u003E\n\u003Cp\u003ERejected requests do not execute their endpoint handlers. Consequently, a request rejected at this layer does not consume a reset or PKCE proof, rotate refresh credentials, clear logout cookies, or increment login failure counts through the rejected handler.\u003C/p\u003E\n\u003Cp\u003EAuthentication and other middleware executing before the rate limiter may still perform work.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022user-facing-messages\u0022\u003EUser-facing messages\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003Erate_limited\u003C/code\u003E is a stable machine-readable error code, not a localized user-facing message.\u003C/p\u003E\n\u003Cp\u003EApplications can map it to a message such as:\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003EToo many requests. Please wait a moment before trying again.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Cp\u003EIf a reliable retry delay is available, the application may display it.\u003C/p\u003E\n\u003Cp\u003EAutomatic background operations should generally handle throttling silently rather than repeatedly notifying the user.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-http-429-handling\u0022\u003EClient HTTP 429 handling\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth clients recognize HTTP 429 as a temporary request throttle.\u003C/p\u003E\n\u003Cp\u003EA 429 response does not establish that:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe user\u0027s credentials are invalid\u003C/li\u003E\n\u003Cli\u003EThe session has been revoked\u003C/li\u003E\n\u003Cli\u003EA token has expired\u003C/li\u003E\n\u003Cli\u003EThe user is unauthorized\u003C/li\u003E\n\u003Cli\u003EReauthentication is required\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\u003Cstrong\u003EA throttled request must not, by itself, clear a valid authenticated session.\u003C/strong\u003E\u003C/p\u003E\n\u003Cp\u003EUltimateAuth uses its existing client result and error contracts rather than introducing a separate rate limiting exception hierarchy.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-result-contracts\u0022\u003EClient result contracts\u003C/h3\u003E\n\u003Cp\u003EThe following contracts carry throttling information where applicable:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ccode\u003EUAuthTransportException\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETryLoginResult\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ETryPkceLoginResult\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003ERefreshResult\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EUAuthResult\u003C/code\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ccode\u003EUAuthProblem\u003C/code\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThe stable error code is \u003Ccode\u003Erate_limited\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EDepending on the contract, it is exposed through \u003Ccode\u003ECode\u003C/code\u003E, \u003Ccode\u003EErrorCode\u003C/code\u003E, or \u003Ccode\u003EProblem.Detail\u003C/code\u003E.\u003C/p\u003E\n\u003Cp\u003EHTTP status remains 429.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthResult.Http.IsTooManyRequests\u003C/code\u003E distinguishes throttling from unauthorized and forbidden results.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ERetryAfter\u003C/code\u003E provides an optional \u003Ccode\u003ETimeSpan\u003C/code\u003E where supported.\u003C/p\u003E\n\u003Cp\u003ETransport DTOs preserve the raw \u003Ccode\u003ERetry-After\u003C/code\u003E header across the JavaScript bridge.\u003C/p\u003E\n\u003Cp\u003EThe existing \u003Ccode\u003EIUAuthTryResult\u003C/code\u003E interface provides default metadata properties so existing implementations are not required to add new members.\u003C/p\u003E\n\u003Cp\u003EThe public \u003Ccode\u003EIFlowClient\u003C/code\u003E method signatures remain unchanged.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-behavior-by-operation\u0022\u003EClient behavior by operation\u003C/h2\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003EOperation\u003C/th\u003E\n\u003Cth\u003EHTTP 429 behavior\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EValidation\u003C/td\u003E\n\u003Ctd\u003EThrows a transport exception with status 429 and throttling metadata\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EBlazor authentication state\u003C/td\u003E\n\u003Ctd\u003EPreserves the current identity, claims, and last successful validation time\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERefresh\u003C/td\u003E\n\u003Ctd\u003EReturns an unsuccessful result with status 429 and \u003Ccode\u003EOutcome = None\u003C/code\u003E\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAutomatic refresh\u003C/td\u003E\n\u003Ctd\u003EDefers subsequent attempts without triggering reauthentication\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogin preview\u003C/td\u003E\n\u003Ctd\u003EReturns a distinguishable throttled result rather than invalid credentials\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EPKCE preview\u003C/td\u003E\n\u003Ctd\u003EReturns a distinguishable throttled result\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ETry-and-commit\u003C/td\u003E\n\u003Ctd\u003EDoes not submit the final commit after a throttled preview\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ELogout\u003C/td\u003E\n\u003Ctd\u003EDoes not report successful logout or navigate on a confirmed 429\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EAuthorization\u003C/td\u003E\n\u003Ctd\u003EReturns a throttled failure, not a Forbidden result\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EProfile, user, credential, and recovery operations\u003C/td\u003E\n\u003Ctd\u003EReturn unsuccessful results without publishing success events\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EResource requests\u003C/td\u003E\n\u003Ctd\u003EPreserve HTTP 429 information without changing authentication state\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022validation\u0022\u003EValidation\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EValidateAsync\u003C/code\u003E throws \u003Ccode\u003EUAuthTransportException\u003C/code\u003E for HTTP 429 before attempting to deserialize a successful authentication result.\u003C/p\u003E\n\u003Cp\u003EBlazor\u0027s \u003Ccode\u003EUAuthStateManager.EnsureAsync\u003C/code\u003E recognizes this condition and preserves the current authentication snapshot.\u003C/p\u003E\n\u003Cp\u003EIt does not:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EClear the current identity\u003C/li\u003E\n\u003Cli\u003ERemove claims\u003C/li\u003E\n\u003Cli\u003ETreat the user as anonymous\u003C/li\u003E\n\u003Cli\u003EMark the session freshly validated\u003C/li\u003E\n\u003Cli\u003EUpdate the last successful validation timestamp\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ESubsequent validation attempts, including forced validation calls, are deferred during the cooldown.\u003C/p\u003E\n\u003Cp\u003EA genuine invalid, expired, or revoked validation result continues to follow the normal state-clearing behavior.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022refresh\u0022\u003ERefresh\u003C/h3\u003E\n\u003Cp\u003EA throttled refresh returns an unsuccessful \u003Ccode\u003ERefreshResult\u003C/code\u003E with:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EText\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-text\u0022\u003EStatusCode: 429\nErrorCode: rate_limited\nOutcome: None\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EMisleading authentication-outcome headers are ignored on a throttled response.\u003C/p\u003E\n\u003Cp\u003ERepeated refresh calls during the cooldown return the throttled result without issuing another HTTP request.\u003C/p\u003E\n\u003Cp\u003EAutomatic refresh scheduling respects both the configured refresh interval and the throttling deadline.\u003C/p\u003E\n\u003Cp\u003EHTTP 429 does not cause reauthentication events, logout, or automatic navigation.\u003C/p\u003E\n\u003Cp\u003EA genuine refresh HTTP 401 can still produce \u003Ccode\u003EReauthRequired\u003C/code\u003E.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022login-and-pkce\u0022\u003ELogin and PKCE\u003C/h3\u003E\n\u003Cp\u003ELogin and PKCE preview operations expose throttling distinctly from invalid credentials.\u003C/p\u003E\n\u003Cp\u003EWhen a TryAndCommit preview is throttled, the client does not submit the final commit form.\u003C/p\u003E\n\u003Cp\u003EHowever, final browser-navigation form submissions retain their existing navigation semantics.\u003C/p\u003E\n\u003Cp\u003EFor \u003Ccode\u003ELoginAsync\u003C/code\u003E, DirectCommit, \u003Ccode\u003ECompletePkceLoginAsync\u003C/code\u003E, and the final TryAndCommit submission, the browser owns the resulting HTTP response.\u003C/p\u003E\n\u003Cp\u003EIf that final POST receives HTTP 429, the browser may display the server response page.\u003C/p\u003E\n\u003Cp\u003EThe JavaScript client cannot reliably return the final navigated response\u0027s \u003Ccode\u003ERetry-After\u003C/code\u003E metadata through the existing navigation contract.\u003C/p\u003E\n\u003Cp\u003EPreview success or form submission completion must not be interpreted as confirmation that a session was issued.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022logout\u0022\u003ELogout\u003C/h3\u003E\n\u003Cp\u003ELogout uses an observed form POST so the client can distinguish a confirmed successful response from HTTP 429.\u003C/p\u003E\n\u003Cp\u003EOn HTTP 429:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ENo successful logout event is published.\u003C/li\u003E\n\u003Cli\u003ENo success navigation occurs.\u003C/li\u003E\n\u003Cli\u003EThe client does not clear authentication state solely because of throttling.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EOn confirmed success, the existing logout event can update client authentication state.\u003C/p\u003E\n\u003Cp\u003ELogout navigation is different from login navigation: the observed request can follow a successful server redirect and then navigate without replaying the logout POST.\u003C/p\u003E\n\u003Cp\u003ECross-origin logout still requires compatible browser credentials, CORS configuration, and redirect behavior.\u003C/p\u003E\n\u003Cp\u003EA network or redirect failure may leave the server-side logout outcome uncertain. The SDK reports an error rather than claiming success or automatically replaying the operation.\u003C/p\u003E\n\u003Cp\u003EApplications can explicitly revalidate when connectivity permits.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022custom-client-transports\u0022\u003ECustom client transports\u003C/h3\u003E\n\u003Cp\u003E\u003Ccode\u003EIUAuthRequestClient\u003C/code\u003E supports an observed form request overload:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003ESendFormAsync(endpoint, form, navigateOnSuccess, ct)\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe overload has a default implementation that delegates to the original form request method.\u003C/p\u003E\n\u003Cp\u003EExisting custom transport implementations therefore retain their original interface requirements.\u003C/p\u003E\n\u003Cp\u003EHowever, the default implementation does not provide navigation behavior automatically.\u003C/p\u003E\n\u003Cp\u003ECustom adapters that support logout navigation should implement the observed overload and preserve HTTP status and \u003Ccode\u003ERetry-After\u003C/code\u003E metadata.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022retry-after-and-cooldown-behavior\u0022\u003ERetry-After and cooldown behavior\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth clients support standard \u003Ccode\u003ERetry-After\u003C/code\u003E values expressed as:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EDelta seconds\u003C/li\u003E\n\u003Cli\u003EHTTP-date values\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EParsing uses invariant culture and UTC.\u003C/p\u003E\n\u003Cp\u003EMissing, malformed, or inaccessible values remain null in application results. Past dates produce a zero retry delay.\u003C/p\u003E\n\u003Cp\u003EFor internal maintenance scheduling, the SDK uses:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA conservative 30-second fallback when retry guidance is unavailable\u003C/li\u003E\n\u003Cli\u003EA one-second minimum to avoid immediate retry loops\u003C/li\u003E\n\u003Cli\u003EBounded timer intervals when waiting for long delays\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ELong retry guidance is honored by the current implementation.\u003C/p\u003E\n\u003Cp\u003EThe SDK does not automatically retry arbitrary application operations.\u003C/p\u003E\n\u003Cp\u003EIn particular, it does not automatically replay login, logout, recovery, credential mutations, or administrative changes after throttling.\u003C/p\u003E\n\u003Cp\u003EFor cross-origin browser clients, the server must expose \u003Ccode\u003ERetry-After\u003C/code\u003E through CORS if JavaScript is expected to read that header.\u003C/p\u003E\n\u003Cp\u003EWhen the header is not accessible, the client follows its missing-header behavior.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022authentication-state-safety\u0022\u003EAuthentication state safety\u003C/h2\u003E\n\u003Cp\u003ERate limiting is deliberately separated from authentication validity.\u003C/p\u003E\n\u003Cp\u003EA throttled request does not:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EExtend token expiration\u003C/li\u003E\n\u003Cli\u003ERenew credentials\u003C/li\u003E\n\u003Cli\u003EUpdate successful validation timestamps\u003C/li\u003E\n\u003Cli\u003ECreate a trusted identity\u003C/li\u003E\n\u003Cli\u003ERevoke an existing session\u003C/li\u003E\n\u003Cli\u003EClear browser storage automatically\u003C/li\u003E\n\u003Cli\u003EPublish successful mutation events\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthStateManager\u003C/code\u003E remains responsible for authentication state transitions.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003ESessionCoordinator\u003C/code\u003E remains responsible for automatic refresh scheduling and genuine reauthentication handling.\u003C/p\u003E\n\u003Cp\u003E\u003Ccode\u003EUAuthAuthenticationStateProvider\u003C/code\u003E projects the retained state to Blazor.\u003C/p\u003E\n\u003Cp\u003EExplicit local actions such as \u003Ccode\u003EClear\u003C/code\u003E and \u003Ccode\u003EOnLogoutAsync\u003C/code\u003E, and authoritative revocation or mutation events, retain their existing behavior.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022partitioning-and-trust-boundaries\u0022\u003EPartitioning and trust boundaries\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth chooses rate limiting partitions using trusted server-side information.\u003C/p\u003E\n\u003Cp\u003ECredential entry, anonymous registration and recovery, identifier availability, PKCE, Hub entry, and self-proof verification use the connection IP even when a cookie is present.\u003C/p\u003E\n\u003Cp\u003EOther operations use validated UltimateAuth tenant and actor information when available, otherwise the connection IP.\u003C/p\u003E\n\u003Cp\u003EThe following do not independently establish a trusted authenticated partition:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EMere presence of a session cookie\u003C/li\u003E\n\u003Cli\u003EUsernames or target user identifiers\u003C/li\u003E\n\u003Cli\u003EUnvalidated tokens\u003C/li\u003E\n\u003Cli\u003EDevice identifiers\u003C/li\u003E\n\u003Cli\u003EClient profile hints\u003C/li\u003E\n\u003Cli\u003ERaw tenant selectors\u003C/li\u003E\n\u003Cli\u003EPrincipals from unrelated authentication authorities\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EAuthenticated actor partitions include validated tenant identity.\u003C/p\u003E\n\u003Cp\u003EAnonymous IP budgets span tenants, preventing a client from multiplying its quota simply by rotating tenant selectors.\u003C/p\u003E\n\u003Cp\u003EIPv4-mapped IPv6 addresses are normalized to IPv4.\u003C/p\u003E\n\u003Cp\u003ERequests without an available connection address share an unknown-address partition.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth does not trust arbitrary forwarding headers.\u003C/p\u003E\n\u003Cp\u003EConfigure trusted proxies, forwarded headers, and ingress restrictions at the application or infrastructure level.\u003C/p\u003E\n\u003Cp\u003EApplications using another identity authority should configure an appropriate native policy rather than assuming UltimateAuth can validate that authority\u0027s principal.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022persistence-and-distributed-deployment\u0022\u003EPersistence and distributed deployment\u003C/h2\u003E\n\u003Cp\u003EBuilt-in counters are maintained in application memory.\u003C/p\u003E\n\u003Cp\u003EThey are not stored in UltimateAuth\u0027s persistence providers, EF Core, or a database.\u003C/p\u003E\n\u003Cp\u003EThis means:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003ECounters reset when the application process restarts.\u003C/li\u003E\n\u003Cli\u003EEach application instance maintains its own counters.\u003C/li\u003E\n\u003Cli\u003EBrowser refreshes do not reset server counters.\u003C/li\u003E\n\u003Cli\u003ESliding windows replenish capacity over time.\u003C/li\u003E\n\u003Cli\u003EMultiple application instances do not automatically share a global request budget.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EFor distributed deployments requiring fleet-wide limits, use an ingress gateway, WAF, or custom native policy backed by shared infrastructure.\u003C/p\u003E\n\u003Cp\u003EBuilt-in native partitions have idle cleanup, but do not provide a hard partition-cardinality bound.\u003C/p\u003E\n\u003Cp\u003ELarge-scale IP rotation, IPv6 address churn, NAT sharing, and botnet traffic require additional operational consideration.\u003C/p\u003E\n\u003Cp\u003ERate limiting does not replace account lockout, proof-attempt limits, anti-enumeration responses, or other authentication security controls.\u003C/p\u003E\n\u003Cp\u003EDirect service calls and Blazor Server circuit operations are outside HTTP endpoint throttling unless the application protects them separately.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022testing-rate-limiting\u0022\u003ETesting rate limiting\u003C/h2\u003E\n\u003Cp\u003EA simple development test is to temporarily limit validation to two requests per minute:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EC#\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(options =\u0026gt;\n{\n options.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new()\n {\n PermitLimit = 2,\n Window = TimeSpan.FromMinutes(1)\n };\n});\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EAfter starting the application and authenticating, send repeated requests to:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-code-wrapper\u0022\u003E\u003Cdiv class=\u0022docs-code-header\u0022\u003E\u003Cspan class=\u0022docs-code-language\u0022\u003EHTTP\u003C/span\u003E\u003Cbutton type=\u0022button\u0022 class=\u0022docs-code-copy\u0022 aria-label=\u0022Copy code\u0022\u003ECopy\u003C/button\u003E\u003C/div\u003E\u003Cpre\u003E\u003Ccode class=\u0022language-http\u0022\u003EPOST /auth/validate\n\u003C/code\u003E\u003C/pre\u003E\u003C/div\u003E\n\u003Cp\u003EThe expected server behavior is:\u003C/p\u003E\n\u003Cdiv class=\u0022docs-table-wrapper\u0022\u003E\u003Ctable\u003E\n\u003Cthead\u003E\n\u003Ctr\u003E\n\u003Cth\u003ERequest\u003C/th\u003E\n\u003Cth\u003EExpected result\u003C/th\u003E\n\u003C/tr\u003E\n\u003C/thead\u003E\n\u003Ctbody\u003E\n\u003Ctr\u003E\n\u003Ctd\u003EFirst permitted request\u003C/td\u003E\n\u003Ctd\u003ENormal validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ESecond permitted request\u003C/td\u003E\n\u003Ctd\u003ENormal validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequest exceeding the budget\u003C/td\u003E\n\u003Ctd\u003EHTTP 429\u003C/td\u003E\n\u003C/tr\u003E\n\u003Ctr\u003E\n\u003Ctd\u003ERequest after capacity replenishes\u003C/td\u003E\n\u003Ctd\u003ENormal validation\u003C/td\u003E\n\u003C/tr\u003E\n\u003C/tbody\u003E\n\u003C/table\u003E\u003C/div\u003E\n\u003Cp\u003EActual results depend on the partition\u0027s existing traffic and sliding-window state.\u003C/p\u003E\n\u003Cp\u003EWhen testing through an UltimateAuth client, the client may defer additional validation requests after receiving HTTP 429. This is expected and should be tested separately from server enforcement.\u003C/p\u003E\n\u003Cp\u003EAlso verify that:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EAuthentication state remains intact after throttling.\u003C/li\u003E\n\u003Cli\u003EOther independently limited operations continue to work.\u003C/li\u003E\n\u003Cli\u003EAutomatic refresh does not enter an immediate retry loop.\u003C/li\u003E\n\u003Cli\u003ELogin preview does not confuse throttling with invalid credentials.\u003C/li\u003E\n\u003Cli\u003ELogout does not report success after a confirmed 429.\u003C/li\u003E\n\u003Cli\u003ENo successful mutation event is published for a rejected operation.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ERemove temporary low-limit overrides before deploying to production.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022client-ip-address-reverse-proxies\u0022\u003EClient IP Address \u0026amp; Reverse Proxies\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth uses ASP.NET Core\u0027s resolved client IP address for IP-based rate limiting. It does not directly trust forwarded IP headers.\u003C/p\u003E\n\u003Cp\u003EWhen deploying behind Caddy, Nginx, Cloudflare, or another reverse proxy, ensure that ASP.NET Core\u0027s Forwarded Headers Middleware is correctly configured with trusted proxies or networks.\u003C/p\u003E\n\u003Cp\u003EWithout proper configuration, multiple users may be identified by the same proxy IP address and share rate limits.\u003C/p\u003E\n\u003Cp\u003ESee \u003Ca href=\u0022/docs/getting-started/real-world-setup#10-client-ip-address--reverse-proxy\u0022\u003EClient IP Address \u0026amp; Reverse Proxy Setup\u003C/a\u003E for configuration examples.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022related-documentation\u0022\u003ERelated documentation\u003C/h2\u003E\n\u003Cul\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/server-options\u0022\u003EServer Options\u003C/a\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/configuration/client-options\u0022\u003EClient Options\u003C/a\u003E\u003C/li\u003E\n\u003Cli\u003E\u003Ca href=\u0022/docs/concepts/authentication-model\u0022\u003EAuthentication Model\u003C/a\u003E\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003ERate limiting is one layer of UltimateAuth\u0027s security model. It should be combined with appropriate credential policies, session validation, trusted proxy configuration, and deployment-level protection.\u003C/p\u003E\n", + "Headings": [ + { + "Id": "getting-started", + "Text": "Getting started", + "Level": 0 + }, + { + "Id": "middleware-integration", + "Text": "Middleware integration", + "Level": 0 + }, + { + "Id": "configuration-examples", + "Text": "Configuration examples", + "Level": 1 + }, + { + "Id": "existing-application-rate-limiters", + "Text": "Existing application rate limiters", + "Level": 1 + }, + { + "Id": "rules-and-default-limits", + "Text": "Rules and default limits", + "Level": 0 + }, + { + "Id": "shared-login-budget", + "Text": "Shared login budget", + "Level": 1 + }, + { + "Id": "additional-sensitive-defaults", + "Text": "Additional sensitive defaults", + "Level": 1 + }, + { + "Id": "per-endpoint-configuration", + "Text": "Per-endpoint configuration", + "Level": 0 + }, + { + "Id": "policy-precedence", + "Text": "Policy precedence", + "Level": 1 + }, + { + "Id": "disabling-rate-limiting", + "Text": "Disabling rate limiting", + "Level": 0 + }, + { + "Id": "using-application-owned-policies", + "Text": "Using application-owned policies", + "Level": 0 + }, + { + "Id": "rejection-callbacks", + "Text": "Rejection callbacks", + "Level": 1 + }, + { + "Id": "http-429-responses", + "Text": "HTTP 429 responses", + "Level": 0 + }, + { + "Id": "user-facing-messages", + "Text": "User-facing messages", + "Level": 1 + }, + { + "Id": "client-http-429-handling", + "Text": "Client HTTP 429 handling", + "Level": 0 + }, + { + "Id": "client-result-contracts", + "Text": "Client result contracts", + "Level": 1 + }, + { + "Id": "client-behavior-by-operation", + "Text": "Client behavior by operation", + "Level": 0 + }, + { + "Id": "validation", + "Text": "Validation", + "Level": 1 + }, + { + "Id": "refresh", + "Text": "Refresh", + "Level": 1 + }, + { + "Id": "login-and-pkce", + "Text": "Login and PKCE", + "Level": 1 + }, + { + "Id": "logout", + "Text": "Logout", + "Level": 1 + }, + { + "Id": "custom-client-transports", + "Text": "Custom client transports", + "Level": 1 + }, + { + "Id": "retry-after-and-cooldown-behavior", + "Text": "Retry-After and cooldown behavior", + "Level": 0 + }, + { + "Id": "authentication-state-safety", + "Text": "Authentication state safety", + "Level": 0 + }, + { + "Id": "partitioning-and-trust-boundaries", + "Text": "Partitioning and trust boundaries", + "Level": 0 + }, + { + "Id": "persistence-and-distributed-deployment", + "Text": "Persistence and distributed deployment", + "Level": 0 + }, + { + "Id": "testing-rate-limiting", + "Text": "Testing rate limiting", + "Level": 0 + }, + { + "Id": "client-ip-address-reverse-proxies", + "Text": "Client IP Address \u0026 Reverse Proxies", + "Level": 1 + }, + { + "Id": "related-documentation", + "Text": "Related documentation", + "Level": 0 + } + ], + "SourceHash": "BFDB17C67297EBF0E46C4481A0221ECC54E04A67135EF223BA26DFE64B559B93", + "BuilderVersion": "1" +} \ No newline at end of file diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs index 9dc2ee6b..1b1cd3fa 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs @@ -78,12 +78,16 @@ builder.Services.AddScopedUltimateAuthSampleSeed(); builder.Services.AddScoped(); -builder.Services.Configure(options => -{ - options.ForwardedHeaders = - ForwardedHeaders.XForwardedFor | - ForwardedHeaders.XForwardedProto; -}); +// Reverse proxy deployments (Caddy, Nginx, etc.): +// Configure forwarded headers only when running behind a trusted proxy. +// +// builder.Services.Configure(options => +// { +// options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; +// +// // Replace with the actual trusted proxy address. +// options.KnownProxies.Add(IPAddress.Parse("192.0.2.10")); +// }); var app = builder.Build(); @@ -103,7 +107,10 @@ } await app.SeedUltimateAuthAsync(); } -app.UseForwardedHeaders(); + +// Enable only when trusted forwarded headers are configured. +// Must run before authentication, rate limiting and HTTPS redirection. +// app.UseForwardedHeaders(); app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs index e58f9b28..bedc9fb6 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -89,12 +89,16 @@ builder.Services.AddScoped(); } -builder.Services.Configure(options => -{ - options.ForwardedHeaders = - ForwardedHeaders.XForwardedFor | - ForwardedHeaders.XForwardedProto; -}); +// Reverse proxy deployments (Caddy, Nginx, etc.): +// Configure forwarded headers only when running behind a trusted proxy. +// +// builder.Services.Configure(options => +// { +// options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; +// +// // Replace with the actual trusted proxy address. +// options.KnownProxies.Add(IPAddress.Parse("192.0.2.10")); +// }); var app = builder.Build(); @@ -111,7 +115,9 @@ await app.SeedUltimateAuthAsync(); } -app.UseForwardedHeaders(); +// Enable only when trusted forwarded headers are configured. +// Must run before authentication, rate limiting and HTTPS redirection. +// app.UseForwardedHeaders(); app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs index daeda5ba..6c1f2cb3 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs @@ -4,6 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public interface IUAuthTryResult { + int? Status => null; + string? ErrorCode => null; + TimeSpan? RetryAfter => null; bool IsSuccess { get; } AuthFailureReason? Reason { get; } int? RemainingAttempts { get; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs index a5f692f8..e0c6dcfb 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs @@ -7,6 +7,7 @@ public sealed class UAuthProblem public string? Title { get; set; } public int? Status { get; set; } public string? Detail { get; set; } + public TimeSpan? RetryAfter { get; set; } public string? TraceId { get; set; } public Dictionary? Extensions { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs index 2a674d30..198d540d 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -24,6 +24,7 @@ internal HttpStatusInfo(int status) public bool IsBadRequest => _status == 400; public bool IsUnauthorized => _status == 401; + public bool IsTooManyRequests => _status == 429; public bool IsForbidden => _status == 403; public bool IsConflict => _status == 409; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs index ce08a546..f7e8280f 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs @@ -4,6 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TryLoginResult : IUAuthTryResult { + public int? Status { get; init; } + public string? ErrorCode { get; init; } + public TimeSpan? RetryAfter { get; init; } public bool IsSuccess { get; init; } public AuthFailureReason? Reason { get; init; } public int? RemainingAttempts { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs index 6efd7351..c2a8e826 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs @@ -4,6 +4,9 @@ namespace CodeBeam.UltimateAuth.Core.Contracts; public sealed record TryPkceLoginResult : IUAuthTryResult { + public int? Status { get; init; } + public string? ErrorCode { get; init; } + public TimeSpan? RetryAfter { get; init; } public bool IsSuccess { get; init; } public AuthFailureReason? Reason { get; init; } public int? RemainingAttempts { get; init; } diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs index fd8d351d..c4425397 100644 --- a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -73,6 +73,8 @@ protected override async Task HandleAuthenticateAsync() if (snapshot is null || snapshot.Identity is null) return AuthenticateResult.NoResult(); + RateLimiting.UAuthRateLimitPolicy.SetValidatedActor(Context, tenant.Value, result.UserKey.Value.Value); + var principal = snapshot.ToClaimsPrincipal(UAuthConstants.SchemeDefaults.GlobalScheme); return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthConstants.SchemeDefaults.GlobalScheme)); } diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index 175b07d3..e2fe123f 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -51,6 +51,17 @@ public async Task LogoutAsync(HttpContext ctx) var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); + var observedLogout = string.Equals(ctx.Request.Headers["X-UAuth-Logout-Mode"].ToString(), "observe" ,StringComparison.OrdinalIgnoreCase); + + if (observedLogout) + { + return Results.Ok(new + { + IsSuccess = true, + RedirectUrl = decision.Enabled ? decision.TargetUrl : null + }); + } + return decision.Enabled ? Results.Redirect(decision.TargetUrl!) : Results.Ok(new LogoutResponse { IsSuccess = true }); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs index a2dfbb16..6ba447e7 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Auth; @@ -37,6 +38,11 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options : rootGroup.MapGroup("/" + basePrefix); group.AddEndpointFilter(); + ((IEndpointConventionBuilder)group).Finally(endpoint => + { + if (!endpoint.Metadata.OfType().Any()) + throw new InvalidOperationException("Every UltimateAuth endpoint must declare a rate limiting classification."); + }); //var user = group.MapGroup(""); var self = group.MapGroup("/me"); @@ -45,32 +51,32 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (options.Endpoints.Authentication != false) { group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) - => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)).WithUAuthRateLimit(UAuthEndpoint.Login); group.MapPost("/try-login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) - => await h.TryLoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.TryLoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)).WithUAuthRateLimit(UAuthEndpoint.TryLogin); group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) - => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)); + => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)).WithUAuthRateLimit(UAuthEndpoint.Validate); if (Enabled(UAuthActions.Flows.LogoutSelf)) group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)).WithUAuthRateLimit(UAuthEndpoint.Logout); if (Enabled(UAuthActions.Flows.LogoutDeviceSelf)) self.MapPost("/logout-device", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutDeviceSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutDeviceSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)).WithUAuthRateLimit(UAuthEndpoint.LogoutDeviceSelf); if (Enabled(UAuthActions.Flows.LogoutOthersSelf)) self.MapPost("/logout-others", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutOthersSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutOthersSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)).WithUAuthRateLimit(UAuthEndpoint.LogoutOthersSelf); if (Enabled(UAuthActions.Flows.LogoutAllSelf)) self.MapPost("/logout-all", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) - => await h.LogoutAllSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutAllSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)).WithUAuthRateLimit(UAuthEndpoint.LogoutAllSelf); group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) - => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); + => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)).WithUAuthRateLimit(UAuthEndpoint.Refresh); //group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) // => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); @@ -78,15 +84,15 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (Enabled(UAuthActions.Flows.LogoutDeviceAdmin)) admin.MapPost("/users/{userKey}/logout-device", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.LogoutDeviceAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutDeviceAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)).WithUAuthRateLimit(UAuthEndpoint.LogoutDeviceAdmin); if (Enabled(UAuthActions.Flows.LogoutOthersAdmin)) admin.MapPost("/users/{userKey}/logout-others", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.LogoutOthersAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutOthersAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)).WithUAuthRateLimit(UAuthEndpoint.LogoutOthersAdmin); if (Enabled(UAuthActions.Flows.LogoutAllAdmin)) admin.MapPost("/users/{userKey}/logout-all", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.LogoutAllAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + => await h.LogoutAllAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)).WithUAuthRateLimit(UAuthEndpoint.LogoutAllAdmin); } if (options.Endpoints.Pkce != false) @@ -94,13 +100,13 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options var pkce = group.MapGroup("/pkce"); pkce.MapPost("/authorize", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.AuthorizeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.AuthorizeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)).WithUAuthRateLimit(UAuthEndpoint.PkceAuthorize); pkce.MapPost("/complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)).WithUAuthRateLimit(UAuthEndpoint.PkceComplete); pkce.MapPost("/try-complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) - => await h.TryCompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + => await h.TryCompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)).WithUAuthRateLimit(UAuthEndpoint.PkceTryComplete); } //if (options.Endpoints.Token != false) @@ -127,48 +133,48 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (Enabled(UAuthActions.Sessions.ListChainsSelf)) selfSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) - => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)).WithUAuthRateLimit(UAuthEndpoint.GetMyChains); if (Enabled(UAuthActions.Sessions.GetChainSelf)) selfSession.MapPost("/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) - => await h.GetMyChainDetailAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + => await h.GetMyChainDetailAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)).WithUAuthRateLimit(UAuthEndpoint.GetMyChainDetail); if (Enabled(UAuthActions.Sessions.RevokeChainSelf)) selfSession.MapPost("/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) - => await h.RevokeMyChainAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeMyChainAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)).WithUAuthRateLimit(UAuthEndpoint.RevokeMyChain); if (Enabled(UAuthActions.Sessions.RevokeOtherChainsSelf)) selfSession.MapPost("/revoke-others",async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) - => await h.RevokeOtherChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeOtherChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)).WithUAuthRateLimit(UAuthEndpoint.RevokeOtherChains); if (Enabled(UAuthActions.Sessions.RevokeAllChainsSelf)) selfSession.MapPost("/revoke-all", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) - => await h.RevokeAllMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeAllMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)).WithUAuthRateLimit(UAuthEndpoint.RevokeAllMyChains); if (Enabled(UAuthActions.Sessions.ListChainsAdmin)) adminSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserChainsAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + => await h.GetUserChainsAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)).WithUAuthRateLimit(UAuthEndpoint.GetUserChains); if (Enabled(UAuthActions.Sessions.GetChainAdmin)) adminSession.MapPost("/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) - => await h.GetUserChainDetailAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + => await h.GetUserChainDetailAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)).WithUAuthRateLimit(UAuthEndpoint.GetUserChainDetail); if (Enabled(UAuthActions.Sessions.RevokeSessionAdmin)) adminSession.MapPost("/{sessionId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, AuthSessionId sessionId, HttpContext ctx) - => await h.RevokeUserSessionAsync(userKey, sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeUserSessionAsync(userKey, sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)).WithUAuthRateLimit(UAuthEndpoint.RevokeUserSession); if (Enabled(UAuthActions.Sessions.RevokeChainAdmin)) adminSession.MapPost("/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) - => await h.RevokeUserChainAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeUserChainAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)).WithUAuthRateLimit(UAuthEndpoint.RevokeUserChain); if (Enabled(UAuthActions.Sessions.RevokeRootAdmin)) adminSession.MapPost("/revoke-root", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RevokeRootAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeRootAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)).WithUAuthRateLimit(UAuthEndpoint.RevokeRoot); if (Enabled(UAuthActions.Sessions.RevokeAllChainsAdmin)) adminSession.MapPost("/revoke-all", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RevokeAllChainsAsync(userKey, null, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + => await h.RevokeAllChainsAsync(userKey, null, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)).WithUAuthRateLimit(UAuthEndpoint.RevokeAllChains); } //if (options.EnableUserInfoEndpoints != false) @@ -189,136 +195,136 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options { if (Enabled(UAuthActions.Users.CreateAnonymous)) group.MapPost("/users/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.UserCreateAnonymous); if (Enabled(UAuthActions.Users.ChangeStatusSelf)) self.MapPost("/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.ChangeStatusSelf); if (Enabled(UAuthActions.Users.DeleteSelf)) self.MapPost("/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.DeleteMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.DeleteMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.UserDeleteSelf); if (Enabled(UAuthActions.Users.QueryAdmin)) adminUsers.MapPost("/query", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.QueryUsersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.QueryUsersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.QueryUsers); if (Enabled(UAuthActions.Users.GetByKeysAdmin)) adminUsers.MapPost("/get-by-keys", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.GetUsersByKeysAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.GetUsersByKeysAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.GetUsersByKeys); if (Enabled(UAuthActions.Users.CreateAdmin)) adminUsers.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.CreateAdminAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.CreateAdminAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.UserCreateAdmin); if (Enabled(UAuthActions.Users.ChangeStatusAdmin)) adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.ChangeStatusAdmin); if (Enabled(UAuthActions.Users.DeleteAdmin)) adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)).WithUAuthRateLimit(UAuthEndpoint.UserDeleteAdmin); } if (options.Endpoints.UserProfile != false) { if (Enabled(UAuthActions.UserProfiles.GetSelf)) self.MapPost("/profile/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.ProfileGetSelf); if (Enabled(UAuthActions.UserProfiles.CreateSelf)) self.MapPost("/profile/create", async (IUserEndpointHandler h, HttpContext ctx) - => await h.CreateProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.CreateProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.CreateProfileSelf); if (Enabled(UAuthActions.UserProfiles.UpdateSelf)) self.MapPost("/profile/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.ProfileUpdateSelf); if (Enabled(UAuthActions.UserProfiles.DeleteSelf)) self.MapPost("/profile/delete", async (IUserEndpointHandler h, HttpContext ctx) - => await h.DeleteProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.DeleteProfileSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.DeleteProfileSelf); if (Enabled(UAuthActions.UserProfiles.GetAdmin)) adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.ProfileGetAdmin); if (Enabled(UAuthActions.UserProfiles.CreateAdmin)) adminUsers.MapPost("/{userKey}/profile/create", async (IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.CreateProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.CreateProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.CreateProfileAdmin); if (Enabled(UAuthActions.UserProfiles.UpdateAdmin)) adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.ProfileUpdateAdmin); if (Enabled(UAuthActions.UserProfiles.DeleteAdmin)) adminUsers.MapPost("/{userKey}/profile/delete", async (IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + => await h.DeleteProfileAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)).WithUAuthRateLimit(UAuthEndpoint.DeleteProfileAdmin); } if (options.Endpoints.UserIdentifier != false) { if (Enabled(UAuthActions.UserIdentifiers.GetSelf)) self.MapPost("/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.GetMyIdentifiers); if (Enabled(UAuthActions.UserIdentifiers.AddSelf)) self.MapPost("/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.AddUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.UpdateSelf)) self.MapPost("/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.UpdateUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.SetPrimarySelf)) self.MapPost("/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.SetPrimaryUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimarySelf)) self.MapPost("/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.UnsetPrimaryUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.VerifySelf)) self.MapPost("/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.VerifyUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.DeleteSelf)) self.MapPost("/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.DeleteUserIdentifierSelf); if (Enabled(UAuthActions.UserIdentifiers.GetAdmin)) adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.GetUserIdentifiers); if (Enabled(UAuthActions.UserIdentifiers.AddAdmin)) adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.AddUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.UpdateAdmin)) adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.UpdateUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.SetPrimaryAdmin)) adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.SetPrimaryUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimaryAdmin)) adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.UnsetPrimaryUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.VerifyAdmin)) adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.VerifyUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.DeleteAdmin)) adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.DeleteUserIdentifierAdmin); if (Enabled(UAuthActions.UserIdentifiers.CheckAvailability)) self.MapPost("/identifiers/check-availability", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) - => await h.CheckIdentifierAvailabilityAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + => await h.CheckIdentifierAvailabilityAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)).WithUAuthRateLimit(UAuthEndpoint.CheckIdentifierAvailability); } if (options.Endpoints.Credentials != false) @@ -328,48 +334,48 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options if (Enabled(UAuthActions.Credentials.AddSelf)) selfCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialAddSelf); if (Enabled(UAuthActions.Credentials.ChangeSelf)) selfCredentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialChangeSelf); if (Enabled(UAuthActions.Credentials.RevokeSelf)) selfCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialRevokeSelf); if (Enabled(UAuthActions.Credentials.BeginResetAnonymous)) selfCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialResetBeginAnonymous); if (Enabled(UAuthActions.Credentials.CompleteResetAnonymous)) selfCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) - => await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialResetCompleteAnonymous); if (Enabled(UAuthActions.Credentials.AddAdmin)) adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialAddAdmin); if (Enabled(UAuthActions.Credentials.ChangeAdmin)) adminCredentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.ChangeSecretAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.ChangeSecretAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialChangeAdmin); if (Enabled(UAuthActions.Credentials.RevokeAdmin)) adminCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialRevokeAdmin); if (Enabled(UAuthActions.Credentials.BeginResetAdmin)) adminCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.BeginResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.BeginResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialResetBeginAdmin); if (Enabled(UAuthActions.Credentials.CompleteResetAdmin)) adminCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.CompleteResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.CompleteResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialResetCompleteAdmin); if (Enabled(UAuthActions.Credentials.DeleteAdmin)) adminCredentials.MapPost("/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.DeleteAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + => await h.DeleteAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)).WithUAuthRateLimit(UAuthEndpoint.CredentialDeleteAdmin); } if (options.Endpoints.Authorization != false) @@ -379,48 +385,48 @@ public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options // TODO: Add enabled actions here selfAuthz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.AuthorizationCheck); if (Enabled(UAuthActions.Authorization.Roles.GetSelf)) selfAuthz.MapPost("/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.GetMyRoles); if (Enabled(UAuthActions.Authorization.Permissions.GetSelf)) selfAuthz.MapPost("/permissions/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.GetMyPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.GetMyPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.GetMyPermissions); if (Enabled(UAuthActions.Authorization.Roles.GetAdmin)) adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.GetUserRoles); if (Enabled(UAuthActions.Authorization.Roles.AssignAdmin)) adminAuthz.MapPost("/users/{userKey}/roles/assign", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.AssignRole); if (Enabled(UAuthActions.Authorization.Roles.RemoveAdmin)) adminAuthz.MapPost("/users/{userKey}/roles/remove", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) - => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.RemoveRole); if (Enabled(UAuthActions.Authorization.Roles.CreateAdmin)) adminAuthz.MapPost("/roles/create",async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.CreateRoleAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.CreateRoleAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.CreateRole); if (Enabled(UAuthActions.Authorization.Roles.QueryAdmin)) adminAuthz.MapPost("/roles/query", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) - => await h.QueryRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.QueryRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.QueryRoles); if (Enabled(UAuthActions.Authorization.Roles.RenameAdmin)) adminAuthz.MapPost("/roles/{roleId}/rename", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) - => await h.RenameRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.RenameRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.RenameRole); if (Enabled(UAuthActions.Authorization.Roles.SetPermissionsAdmin)) adminAuthz.MapPost("/roles/{roleId}/permissions", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) - => await h.SetRolePermissionsAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.SetRolePermissionsAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.SetRolePermissions); if (Enabled(UAuthActions.Authorization.Roles.DeleteAdmin)) adminAuthz.MapPost("/roles/{roleId}/delete", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) - => await h.DeleteRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + => await h.DeleteRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)).WithUAuthRateLimit(UAuthEndpoint.DeleteRole); } // IMPORTANT: diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs index 8b487a53..572cf606 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Authorization; +using Microsoft.AspNetCore.Builder; +using CodeBeam.UltimateAuth.Authorization; using CodeBeam.UltimateAuth.Core; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Core.Defaults; @@ -66,6 +67,7 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s .ValidateOnStart(); services.AddUltimateAuthServerInternal(); + services.AddRateLimiter(_ => { }); return services; } diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs index 5d806c85..fb7c1886 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -10,21 +10,39 @@ namespace CodeBeam.UltimateAuth.Server.Extensions; public static class UltimateAuthApplicationBuilderExtensions { - public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app) + /// Installs UltimateAuth context middleware. Optional throttling requires routing first. + /// Do not combine helper-owned throttling with an independent UseRateLimiter call. + public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app) => + UseUltimateAuth(app, useRateLimiting: false); + + public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app, bool useRateLimiting) { app.UseUAuthExceptionHandling(); app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + if (useRateLimiting) UseOwnedRateLimiter(app); return app; } - public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder app, bool? enableCors = null) + // Both arguments are required to preserve positional bool calls as the original enableCors argument. + /// + /// Configures the UltimateAuth ASP.NET Core middleware pipeline, including CORS, authentication, rate limiting, and authorization. + /// + /// The application builder. + /// + /// Controls CORS middleware registration. When null, CORS is enabled automatically if required by UAuthHub. + /// + /// + /// Enables UltimateAuth's built-in rate limiting middleware. Defaults to true. + /// + /// + /// The application builder. + /// + public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder app, bool? enableCors = null, bool useRateLimiting = true) { - var logger = app.ApplicationServices - .GetRequiredService() - .CreateLogger("UltimateAuth"); + var logger = app.ApplicationServices.GetRequiredService().CreateLogger("UltimateAuth"); var marker = app.ApplicationServices.GetService(); var requiresCors = marker?.RequiresCors == true; @@ -39,10 +57,24 @@ public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicatio app.UseUltimateAuth(); app.UseAuthentication(); + + if (useRateLimiting) + UseOwnedRateLimiter(app); + app.UseAuthorization(); + return app; } + private const string OwnedRateLimiterKey = "CodeBeam.UltimateAuth.RateLimiterInstalled"; + + private static void UseOwnedRateLimiter(IApplicationBuilder app) + { + if (app.Properties.TryGetValue(OwnedRateLimiterKey, out var owner) && ReferenceEquals(owner, app)) return; + app.Properties[OwnedRateLimiterKey] = app; + app.UseRateLimiter(); + } + public static IApplicationBuilder UseUltimateAuthResourceApi(this IApplicationBuilder app) { var logger = app.ApplicationServices diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs index 7aef3c12..aa0af9a5 100644 --- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs @@ -1,4 +1,5 @@ -using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Server.Infrastructure; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -9,7 +10,7 @@ public static class UAuthHubEndpointExtensions public static IEndpointRouteBuilder MapUAuthHub(this IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/auth/uauthhub"); - group.MapPost("/entry", HandleHub.HandleHubEntry); + group.MapPost("/entry", HandleHub.HandleHubEntry).WithUAuthRateLimit(UAuthEndpoint.HubEntry); return endpoints; } diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/ClientIpAddress.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ClientIpAddress.cs new file mode 100644 index 00000000..4d66c17c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/ClientIpAddress.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +// Forwarded headers are processed by the host, with its explicit proxy trust configuration. +internal static class ClientIpAddress +{ + internal static string Resolve(HttpContext context) + { + var ip = context.Connection.RemoteIpAddress; + if (ip?.IsIPv4MappedToIPv6 == true) ip = ip.MapToIPv4(); + return ip?.ToString() ?? "unknown"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs index 8fa959b7..38672239 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -9,7 +9,7 @@ namespace CodeBeam.UltimateAuth.Server.Infrastructure; -// TODO: Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. +// TODO: Consider creating a alternative package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. public sealed class DeviceResolver : IDeviceResolver { private readonly IUserAgentParser _userAgentParser; @@ -40,7 +40,7 @@ public async Task ResolveAsync(HttpContext context) OperatingSystem = parsed.OperatingSystem, Browser = parsed.Browser, UserAgent = ua, - IpAddress = ResolveIp(context) + IpAddress = ClientIpAddress.Resolve(context) }; return deviceInfo; @@ -68,14 +68,4 @@ public async Task ResolveAsync(HttpContext context) return null; } - - private static string? ResolveIp(HttpContext context) - { - var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); - - if (!string.IsNullOrWhiteSpace(forwarded)) - return forwarded.Split(',')[0].Trim(); - - return context.Connection.RemoteIpAddress?.ToString(); - } } diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs index 1c04962c..c5474e56 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -52,6 +52,9 @@ public sealed class UAuthServerOptions // SERVER-ONLY BEHAVIOR // ------------------------------------------------------- + /// Startup-only native endpoint throttling; application policies take precedence. + public RateLimiting.UAuthRateLimitingOptions RateLimiting { get; set; } = new(); + /// /// Defines which authentication modes are allowed to be used by the server. /// This is a safety guardrail, not a mode selection mechanism. @@ -144,6 +147,7 @@ internal UAuthServerOptions Clone() AllowedModes = AllowedModes?.ToArray(), HubDeploymentMode = HubDeploymentMode, + RateLimiting = RateLimiting.Clone(), Login = Login.Clone(), Session = Session.Clone(), Token = Token.Clone(), diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs index e04e77ad..89caef50 100644 --- a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs @@ -7,6 +7,9 @@ public sealed class UAuthServerOptionsValidator : IValidateOptionsStable identities of the built-in HTTP endpoints. +public enum UAuthEndpoint +{ + Login, + TryLogin, + Validate, + Logout, + LogoutDeviceSelf, + LogoutOthersSelf, + LogoutAllSelf, + Refresh, + LogoutDeviceAdmin, + LogoutOthersAdmin, + LogoutAllAdmin, + PkceAuthorize, + PkceComplete, + PkceTryComplete, + GetMyChains, + GetMyChainDetail, + RevokeMyChain, + RevokeOtherChains, + RevokeAllMyChains, + GetUserChains, + GetUserChainDetail, + RevokeUserSession, + RevokeUserChain, + RevokeRoot, + RevokeAllChains, + UserCreateAnonymous, + ChangeStatusSelf, + UserDeleteSelf, + QueryUsers, + GetUsersByKeys, + UserCreateAdmin, + ChangeStatusAdmin, + UserDeleteAdmin, + ProfileGetSelf, + CreateProfileSelf, + ProfileUpdateSelf, + DeleteProfileSelf, + ProfileGetAdmin, + CreateProfileAdmin, + ProfileUpdateAdmin, + DeleteProfileAdmin, + GetMyIdentifiers, + AddUserIdentifierSelf, + UpdateUserIdentifierSelf, + SetPrimaryUserIdentifierSelf, + UnsetPrimaryUserIdentifierSelf, + VerifyUserIdentifierSelf, + DeleteUserIdentifierSelf, + GetUserIdentifiers, + AddUserIdentifierAdmin, + UpdateUserIdentifierAdmin, + SetPrimaryUserIdentifierAdmin, + UnsetPrimaryUserIdentifierAdmin, + VerifyUserIdentifierAdmin, + DeleteUserIdentifierAdmin, + CheckIdentifierAvailability, + CredentialAddSelf, + CredentialChangeSelf, + CredentialRevokeSelf, + CredentialResetBeginAnonymous, + CredentialResetCompleteAnonymous, + CredentialAddAdmin, + CredentialChangeAdmin, + CredentialRevokeAdmin, + CredentialResetBeginAdmin, + CredentialResetCompleteAdmin, + CredentialDeleteAdmin, + AuthorizationCheck, + GetMyRoles, + GetMyPermissions, + GetUserRoles, + AssignRole, + RemoveRole, + CreateRole, + QueryRoles, + RenameRole, + SetRolePermissions, + DeleteRole, + HubEntry, +} + +internal enum UAuthRateLimitGroup { Sensitive, Standard, Frequent } diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs new file mode 100644 index 00000000..d2ff588e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthEndpointClassification.cs @@ -0,0 +1,90 @@ +namespace CodeBeam.UltimateAuth.Server.RateLimiting; + +internal sealed record UAuthRateLimitMetadata(UAuthEndpoint Endpoint, UAuthRateLimitGroup Group, bool IpOnly); + +internal static class UAuthEndpointClassification +{ + internal static UAuthRateLimitMetadata Get(UAuthEndpoint endpoint) => endpoint switch + { + UAuthEndpoint.Login => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.TryLogin => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.Validate => new(endpoint, UAuthRateLimitGroup.Frequent, false), + UAuthEndpoint.Logout => new(endpoint, UAuthRateLimitGroup.Frequent, false), + UAuthEndpoint.LogoutDeviceSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.LogoutOthersSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.LogoutAllSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.Refresh => new(endpoint, UAuthRateLimitGroup.Frequent, false), + UAuthEndpoint.LogoutDeviceAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.LogoutOthersAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.LogoutAllAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.PkceAuthorize => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.PkceComplete => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.PkceTryComplete => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.GetMyChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetMyChainDetail => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeMyChain => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeOtherChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeAllMyChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetUserChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetUserChainDetail => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeUserSession => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeUserChain => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeRoot => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RevokeAllChains => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UserCreateAnonymous => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.ChangeStatusSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UserDeleteSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.QueryUsers => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetUsersByKeys => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UserCreateAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ChangeStatusAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UserDeleteAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ProfileGetSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CreateProfileSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ProfileUpdateSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.DeleteProfileSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ProfileGetAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CreateProfileAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.ProfileUpdateAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.DeleteProfileAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetMyIdentifiers => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.AddUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.UpdateUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.SetPrimaryUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.UnsetPrimaryUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.VerifyUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.DeleteUserIdentifierSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.GetUserIdentifiers => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.AddUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UpdateUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.SetPrimaryUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.UnsetPrimaryUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.VerifyUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.DeleteUserIdentifierAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CheckIdentifierAvailability => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.CredentialAddSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.CredentialChangeSelf => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.CredentialRevokeSelf => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CredentialResetBeginAnonymous => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.CredentialResetCompleteAnonymous => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + UAuthEndpoint.CredentialAddAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CredentialChangeAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CredentialRevokeAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CredentialResetBeginAdmin => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.CredentialResetCompleteAdmin => new(endpoint, UAuthRateLimitGroup.Sensitive, false), + UAuthEndpoint.CredentialDeleteAdmin => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.AuthorizationCheck => new(endpoint, UAuthRateLimitGroup.Frequent, false), + UAuthEndpoint.GetMyRoles => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetMyPermissions => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.GetUserRoles => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.AssignRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RemoveRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.CreateRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.QueryRoles => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.RenameRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.SetRolePermissions => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.DeleteRole => new(endpoint, UAuthRateLimitGroup.Standard, false), + UAuthEndpoint.HubEntry => new(endpoint, UAuthRateLimitGroup.Sensitive, true), + _ => throw new ArgumentOutOfRangeException(nameof(endpoint)) + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitPolicy.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitPolicy.cs new file mode 100644 index 00000000..435e4f21 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitPolicy.cs @@ -0,0 +1,107 @@ +using System.Globalization; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.RateLimiting; + +internal readonly record struct UAuthPartitionKey(string Budget, string Identity); + +internal sealed class UAuthRateLimitPolicy : IRateLimiterPolicy +{ + private static readonly object ActorKey = new(); + private readonly UAuthRateLimitMetadata _metadata; + private readonly UAuthRateLimitRule _rule; + private readonly string _budget; + + internal UAuthRateLimitPolicy(UAuthRateLimitMetadata metadata, UAuthRateLimitRule rule, string budget) + { + _metadata = metadata; _rule = rule.Clone(); _budget = budget; + } + + internal static void SetValidatedActor(HttpContext context, string tenant, string user) => + context.Items[ActorKey] = (tenant, user); + + public RateLimitPartition GetPartition(HttpContext context) + { + var identity = !_metadata.IpOnly && context.Items.TryGetValue(ActorKey, out var actor) + && actor is ValueTuple validated + ? $"user:{validated.Item1.Length}:{validated.Item1}:{validated.Item2}" + : "ip:" + Infrastructure.ClientIpAddress.Resolve(context); + return RateLimitPartition.GetSlidingWindowLimiter(new UAuthPartitionKey(_budget, identity), _ => new() + { + PermitLimit = _rule.PermitLimit, + Window = _rule.Window, + SegmentsPerWindow = 6, + QueueLimit = 0, + AutoReplenishment = true + }); + } + + public Func? OnRejected => RejectAsync; + + private async ValueTask RejectAsync(OnRejectedContext rejected, CancellationToken cancellationToken) + { + var response = rejected.HttpContext.Response; + response.StatusCode = StatusCodes.Status429TooManyRequests; + response.Headers.CacheControl = "no-store"; + if (rejected.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry)) + response.Headers.RetryAfter = Math.Max(1, Math.Ceiling(retry.TotalSeconds)).ToString(CultureInfo.InvariantCulture); + await Results.Problem(statusCode: 429, title: "Too many requests", + detail: "rate_limited", extensions: new Dictionary + { ["traceId"] = rejected.HttpContext.TraceIdentifier }).ExecuteAsync(rejected.HttpContext); + } +} + +internal static class UAuthRateLimitEndpointExtensions +{ + internal static T WithUAuthRateLimit(this T builder, UAuthEndpoint endpoint) where T : IEndpointConventionBuilder + { + var metadata = UAuthEndpointClassification.Get(endpoint); + builder.Add(b => b.Metadata.Add(metadata)); + builder.Finally(b => + { + // Includes policies inherited from application route groups. Native Disable also wins. + if (b.Metadata.OfType().Any() || + b.Metadata.OfType().Any()) return; + var options = b.ApplicationServices.GetRequiredService>().Value.RateLimiting; + var (rule, budget) = options.Resolve(metadata); + if (!options.Enabled || !rule.Enabled) return; + var immediate = new ImmediateConventionBuilder(b); + if (rule.PolicyName is not null) immediate.RequireRateLimiting(rule.PolicyName); + else immediate.RequireRateLimiting(new UAuthRateLimitPolicy(metadata, rule, budget)); + }); + return builder; + } + + // Uses the public native extension to build inline-policy metadata during finalization. + private sealed class ImmediateConventionBuilder(EndpointBuilder endpoint) : IEndpointConventionBuilder + { + public void Add(Action convention) => convention(endpoint); + } +} + +internal static class UAuthRateLimitValidation +{ + internal static string? Validate(UAuthRateLimitingOptions? options) + { + if (options is null || options.Endpoints is null) return "RateLimiting and its Endpoints cannot be null."; + if (options.Endpoints.Keys.Any(key => !Enum.IsDefined(typeof(UAuthEndpoint), key))) + return "RateLimiting.Endpoints contains an unknown endpoint."; + foreach (var rule in new[] { options.Sensitive, options.Standard, options.Frequent, options.Login }.Concat(options.Endpoints.Values)) + { + if (rule is null) return "RateLimiting rules cannot be null."; + if (rule.PolicyName is not null) + { + if (string.IsNullOrWhiteSpace(rule.PolicyName)) return "RateLimiting.PolicyName cannot be blank."; + } + else if (rule.PermitLimit <= 0 || rule.Window.Ticks < 6) + return "RateLimiting requires a positive PermitLimit and a window of at least six ticks."; + } + return null; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitingOptions.cs b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitingOptions.cs new file mode 100644 index 00000000..158aa062 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/RateLimiting/UAuthRateLimitingOptions.cs @@ -0,0 +1,57 @@ +namespace CodeBeam.UltimateAuth.Server.RateLimiting; + +/// Startup-only endpoint rate limiting configuration. Counters are process-local. +public sealed class UAuthRateLimitingOptions +{ + public bool Enabled { get; set; } = true; + public UAuthRateLimitRule Sensitive { get; set; } = new() { PermitLimit = 30 }; + public UAuthRateLimitRule Standard { get; set; } = new() { PermitLimit = 120 }; + public UAuthRateLimitRule Frequent { get; set; } = new() { PermitLimit = 600 }; + /// Shared by Login, TryLogin, PkceComplete and PkceTryComplete unless explicitly overridden. + public UAuthRateLimitRule Login { get; set; } = new() { PermitLimit = 20 }; + /// Complete replacement rules. Disabling removes only UltimateAuth's contribution. + public Dictionary Endpoints { get; set; } = new(); + + internal (UAuthRateLimitRule Rule, string Budget) Resolve(UAuthRateLimitMetadata metadata) + { + if (Endpoints.TryGetValue(metadata.Endpoint, out var rule)) + return (rule, metadata.Endpoint.ToString()); + if (metadata.Endpoint is UAuthEndpoint.Login or UAuthEndpoint.TryLogin or UAuthEndpoint.PkceComplete or UAuthEndpoint.PkceTryComplete) + return (Login, "login-verification"); + rule = metadata.Group switch + { + UAuthRateLimitGroup.Sensitive => Sensitive, + UAuthRateLimitGroup.Standard => Standard, + _ => Frequent + }; + // Explicit endpoint overrides replace these security defaults as well. + rule = metadata.Endpoint switch + { + UAuthEndpoint.UserCreateAnonymous => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(10), Enabled = rule.Enabled, PolicyName = rule.PolicyName }, + UAuthEndpoint.CredentialResetBeginAnonymous => new() { PermitLimit = 5, Window = TimeSpan.FromMinutes(10), Enabled = rule.Enabled, PolicyName = rule.PolicyName }, + UAuthEndpoint.CredentialResetCompleteAnonymous or UAuthEndpoint.VerifyUserIdentifierSelf => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(10), Enabled = rule.Enabled, PolicyName = rule.PolicyName }, + _ => rule + }; + return (rule, metadata.Endpoint.ToString()); + } + + internal UAuthRateLimitingOptions Clone() => new() + { + Enabled = Enabled, + Sensitive = Sensitive.Clone(), + Standard = Standard.Clone(), + Frequent = Frequent.Clone(), + Login = Login.Clone(), + Endpoints = Endpoints.ToDictionary(x => x.Key, x => x.Value.Clone()) + }; +} + +public sealed class UAuthRateLimitRule +{ + public bool Enabled { get; set; } = true; + public int PermitLimit { get; set; } = 30; + public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1); + /// Optional application-owned named native policy; numeric settings are unused. Native policy/middleware rejection precedence applies. Built-in inline policies otherwise own their 429 response. + public string? PolicyName { get; set; } + internal UAuthRateLimitRule Clone() => (UAuthRateLimitRule)MemberwiseClone(); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs index 2c0ad536..96469232 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs @@ -1,5 +1,6 @@ using CodeBeam.UltimateAuth.Client.Abstractions; using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Diagnostics; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Abstractions; @@ -19,6 +20,7 @@ internal sealed class SessionCoordinator : ISessionCoordinator private readonly IClock _clock; private CancellationTokenSource? _cts; + private DateTimeOffset _refreshNotBefore; public event Action? ReauthRequired; @@ -53,8 +55,11 @@ private async Task RunAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { - _diagnostics.SetNextAutomaticRefresh(_clock.UtcNow.Add(interval)); - await Task.Delay(interval, ct); + var delay = _refreshNotBefore - _clock.UtcNow; + if (delay < interval) delay = interval; + _diagnostics.SetNextAutomaticRefresh(UAuthRateLimit.NotBefore(_clock.UtcNow, delay)); + // Chunk very long guidance to stay within native timer bounds; TickAsync still checks the deadline. + await Task.Delay(delay > TimeSpan.FromDays(1) ? TimeSpan.FromDays(1) : delay, ct); _diagnostics.SetNextAutomaticRefresh(null); await TickAsync(); @@ -86,10 +91,17 @@ public async ValueTask DisposeAsync() internal async Task TickAsync() { + if (_clock.UtcNow < _refreshNotBefore) return; _diagnostics.MarkAutomaticRefresh(); var result = await _client.Flows.RefreshAsync(true); + if (result.Status == 429) + { + _refreshNotBefore = UAuthRateLimit.NotBefore(_clock.UtcNow, result.RetryAfter); + return; + } + if (result.Outcome != RefreshOutcome.ReauthRequired) return; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index f35488ff..5c7b3ea1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; using Microsoft.JSInterop; using System.Net; +using System.Text.Json; // TODO: Add fluent helper API like RequiredOk namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; @@ -66,6 +67,24 @@ public async Task SendFormAsync(string endpoint, IDictiona return result; } + public async Task SendFormAsync(string endpoint, IDictionary? form, bool navigateOnSuccess, CancellationToken ct = default) + { + if (!navigateOnSuccess) return await SendFormAsync(endpoint, form, ct); + ct.ThrowIfCancellationRequested(); + await _bootstrapper.EnsureStartedAsync(); + var result = await _js.InvokeAsync("uauth.post", ct, new + { + url = endpoint, + mode = "logout", + data = form, + clientProfile = _options.ClientProfile.ToString(), + trustedOrigin = ResolveTrustedOrigin() + }); + if (result is null) throw new UAuthProtocolException("Invalid logout transport response."); + if (result.Status == 0) throw new UAuthTransportException("Could not confirm server logout."); + return result; + } + public async Task SendJsonAsync(string endpoint, object? payload = default, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -95,7 +114,7 @@ public async Task TryAndCommitAsync(string tryEndpoint, await _bootstrapper.EnsureStartedAsync(); - var response = await _js.InvokeAsync( + var response = await _js.InvokeAsync( "uauth.tryAndCommit", ct, new @@ -107,10 +126,16 @@ public async Task TryAndCommitAsync(string tryEndpoint, trustedOrigin = ResolveTrustedOrigin() }); - if (response is null) + if (response.ValueKind != JsonValueKind.Object) throw new UAuthProtocolException("Invalid tryAndCommit response."); - - return response; + if (response.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Number && status.TryGetInt32(out var code) && code == 429) + throw UAuthRateLimit.Exception(response.TryGetProperty("retryAfter", out var retry) && retry.ValueKind == JsonValueKind.String ? retry.GetString() : null); + try + { + return response.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? throw new UAuthProtocolException("Invalid tryAndCommit response."); + } + catch (JsonException ex) { throw new UAuthProtocolException("Invalid tryAndCommit response.", ex); } } private string? ResolveTrustedOrigin() diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index 9cae6ca0..8c81dbab 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -91,6 +91,9 @@ window.uauth.tryAndCommit = async function (options) { } const status = tryResponse.status; + if (status === 429) { + return { isSuccess: false, status: 429, errorCode: "rate_limited", retryAfter: tryResponse.retryAfter ?? null }; + } const isHttpSuccess = status >= 200 && status < 300; const isClientError = status >= 400 && status < 500; @@ -214,7 +217,9 @@ window.uauth.post = async function (options) { clientProfile, contentType: "form", credentials: "include", - allowCrossOriginCredentials: true + allowCrossOriginCredentials: true, + navigateOnSuccess: mode === "logout", + headers: mode === "logout" ? { "X-UAuth-Logout-Mode": "observe" } : {} }); }; @@ -237,6 +242,8 @@ window.uauth.postJson = function (options) { window.uauth.request = async function (options) { const { + followRedirects = false, + navigateOnSuccess = false, url, method = "GET", payload = null, @@ -314,7 +321,7 @@ window.uauth.request = async function (options) { response = await fetch(requestUrl.href, { method: requestMethod, credentials, - redirect: "error", + redirect: followRedirects ? "follow" : "error", headers, body }); @@ -347,7 +354,24 @@ window.uauth.request = async function (options) { } } + // Only the observed logout transport follows a successful server redirect. + // A rejected POST never navigates, clears storage, or causes a second POST. + if (navigateOnSuccess && response.ok) { + const redirectUrl = responseBody?.redirectUrl; + + if (typeof redirectUrl === "string" && redirectUrl.length > 0) { + const target = new URL(redirectUrl, window.location.origin); + + if (!["http:", "https:"].includes(target.protocol)) { + throw new Error("Invalid logout redirect protocol."); + } + + window.location.assign(target.href); + } + } + return { + retryAfter: response.headers.get("Retry-After"), ok: response.ok, status: response.status, refreshOutcome: response.headers.get("X-UAuth-Refresh"), @@ -490,6 +514,7 @@ window.uauth.resourceRequest = async function (options) { } return { + retryAfter: response.headers.get("Retry-After"), ok: response.ok, status: response.status, body: responseBody, diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index c0c82b59..85a8b886 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:c,commitUrl:l,data:f,clientProfile:o,trustedOrigin:e}=n,a=window.uauth.validateAuthUrl(c,e),v=window.uauth.validateAuthUrl(l,e),s=window.uauth.deviceId;if(!s)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:a,payload:f,clientProfile:o,trustedOrigin:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status,y=i>=200&&i<300,h=i>=400&&i<500;if(!y&&!h)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(h&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=v;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=o??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=s;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:u,mode:f,data:t,clientProfile:i,trustedOrigin:e}=n,r=window.uauth.validateAuthUrl(u,e);if(f==="navigate"){const n=document.createElement("form");n.method="POST";n.action=r;const u=document.createElement("input");u.type="hidden";u.name="__uauth_client_profile";u.value=i??"";n.appendChild(u);const f=document.createElement("input");if(f.type="hidden",f.name="__uauth_device",f.value=window.uauth.deviceId,n.appendChild(f),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}return window.uauth.request({url:r,method:"POST",payload:t,clientProfile:i,contentType:"form",credentials:"include",allowCrossOriginCredentials:!0})};window.uauth.postJson=function(n){const t=window.uauth.validateAuthUrl(n.url,n.trustedOrigin);return window.uauth.request({url:t,method:"POST",payload:n.payload,clientProfile:n.clientProfile,contentType:"json",credentials:"include",allowCrossOriginCredentials:!0})};window.uauth.request=async function(n){const{url:h,method:a="GET",payload:i=null,clientProfile:v="",contentType:c="json",credentials:f="same-origin",headers:y={}}=n;if(!h)throw new Error("Request URL is required.");const e=new URL(h,document.baseURI);if(!["http:","https:"].includes(e.protocol))throw new Error("Unsupported request URL protocol.");const r=a.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(r))throw new Error(`Unsupported HTTP method: ${r}`);if(!["same-origin","include","omit"].includes(f))throw new Error("Invalid credentials policy.");if(f==="include"&&e.origin!==window.location.origin&&!n.allowCrossOriginCredentials)throw new Error("Cross-origin credentials require explicit authorization.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={...y,"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":v,"X-Requested-With":"UAuth"};let u=null;if(i!==null&&i!==undefined){if(r==="GET")throw new Error("GET requests cannot contain a body.");if(c==="json")o["Content-Type"]="application/json",u=JSON.stringify(i);else if(c==="form"){o["Content-Type"]="application/x-www-form-urlencoded";u=new URLSearchParams;for(const[n,t]of Object.entries(i))u.append(n,t??"")}else throw new Error("Unsupported content type.");}let t;try{t=await fetch(e.href,{method:r,credentials:f,redirect:"error",headers:o,body:u})}catch(l){if(l instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw l;}let s=null;if(t.status!==204&&t.status!==205){const n=await t.text();if(n)try{s=JSON.parse(n)}catch{s=n}}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:s}};window.uauth.resourceRequest=async function(n){const{url:o,baseAddress:h,method:v="GET",payload:f=null,clientProfile:y="",credentials:p="Auto",requestId:u}=n;if(!o||typeof o!="string")throw new Error("Resource request URL is required.");const i=h?new URL(h):new URL(window.location.origin+"/");if(!["http:","https:"].includes(i.protocol))throw new Error("Invalid Resource API protocol.");if(i.username||i.password||i.search||i.hash)throw new Error("Invalid Resource API base address.");const t=new URL(o,i);if(t.origin!==i.origin)throw new Error("Resource request target differs from configured API origin.");if(t.username||t.password)throw new Error("Resource URL cannot contain credentials.");if(!["http:","https:"].includes(t.protocol))throw new Error("Unsupported Resource API protocol.");const s=v.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(s))throw new Error("Unsupported Resource API method.");if(s==="GET"&&f!==null)throw new Error("GET requests cannot contain a body.");let r;switch(p.toLowerCase()){case"auto":r=t.origin===window.location.origin?"same-origin":"include";break;case"sameorigin":r="same-origin";break;case"include":r="include";break;case"omit":r="omit";break;default:throw new Error("Invalid Resource credentials mode.");}if(r==="include"&&t.protocol!=="https:"&&!["localhost","127.0.0.1","[::1]"].includes(t.hostname))throw new Error("Cross-origin credential requests require HTTPS.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const c={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":y,"X-Requested-With":"UAuth"};let l=null;f!==null&&f!==undefined&&(c["Content-Type"]="application/json",l=JSON.stringify(f));const a=new AbortController;if(u){if(window.uauth.resourceRequests.has(u))throw new Error("Duplicate Resource request ID.");window.uauth.resourceRequests.set(u,a)}try{const n=await fetch(t.href,{method:s,credentials:r,redirect:"error",headers:c,body:l,signal:a.signal});let i=null;if(n.status!==204&&n.status!==205){const t=await n.text();t.length>0&&(i=t)}return{ok:n.ok,status:n.status,body:i,contentType:n.headers.get("Content-Type")}}catch(e){if(e?.name==="AbortError")throw e;if(e instanceof TypeError)return{ok:!1,status:0,body:null,contentType:null};throw e;}finally{u&&window.uauth.resourceRequests.delete(u)}};window.uauth.resourceRequests=new Map;window.uauth.cancelResourceRequest=function(n){const t=window.uauth.resourceRequests.get(n);return t?(t.abort(),!0):!1};window.uauth.validateAuthUrl=function(n,t=null){if(!n||typeof n!="string")throw new Error("Authentication URL is required.");const r=t?new URL(t,window.location.origin):new URL(window.location.origin),i=new URL(n,document.baseURI);if(!["http:","https:"].includes(r.protocol))throw new Error("Unsupported trusted origin protocol.");if(!["http:","https:"].includes(i.protocol))throw new Error("Unsupported authentication URL protocol.");if(r.username||r.password)throw new Error("Trusted origin cannot contain credentials.");if(i.username||i.password)throw new Error("Authentication URL cannot contain credentials.");if(i.origin!==r.origin)throw new Error("Authentication request target differs from the trusted UAuthHub origin.");return i.href};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:c,commitUrl:l,data:f,clientProfile:o,trustedOrigin:e}=n,a=window.uauth.validateAuthUrl(c,e),v=window.uauth.validateAuthUrl(l,e),s=window.uauth.deviceId;if(!s)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:a,payload:f,clientProfile:o,trustedOrigin:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status;if(i===429)return{isSuccess:!1,status:429,errorCode:"rate_limited",retryAfter:r.retryAfter??null};const y=i>=200&&i<300,h=i>=400&&i<500;if(!y&&!h)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(h&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const u={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(u.isSuccess){const n=document.createElement("form");n.method="POST";n.action=v;for(const t of Object.keys(f)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=f[t]??"";n.appendChild(i)}if(u.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=u.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=o??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=s;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:i,data:t,clientProfile:r,trustedOrigin:e}=n,u=window.uauth.validateAuthUrl(f,e);if(i==="navigate"){const n=document.createElement("form");n.method="POST";n.action=u;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=r??"";n.appendChild(i);const f=document.createElement("input");if(f.type="hidden",f.name="__uauth_device",f.value=window.uauth.deviceId,n.appendChild(f),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}return window.uauth.request({url:u,method:"POST",payload:t,clientProfile:r,contentType:"form",credentials:"include",allowCrossOriginCredentials:!0,navigateOnSuccess:i==="logout",headers:i==="logout"?{"X-UAuth-Logout-Mode":"observe"}:{}})};window.uauth.postJson=function(n){const t=window.uauth.validateAuthUrl(n.url,n.trustedOrigin);return window.uauth.request({url:t,method:"POST",payload:n.payload,clientProfile:n.clientProfile,contentType:"json",credentials:"include",allowCrossOriginCredentials:!0})};window.uauth.request=async function(n){const{followRedirects:a=!1,navigateOnSuccess:v=!1,url:h,method:y="GET",payload:i=null,clientProfile:p="",contentType:c="json",credentials:e="same-origin",headers:w={}}=n;if(!h)throw new Error("Request URL is required.");const o=new URL(h,document.baseURI);if(!["http:","https:"].includes(o.protocol))throw new Error("Unsupported request URL protocol.");const r=y.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(r))throw new Error(`Unsupported HTTP method: ${r}`);if(!["same-origin","include","omit"].includes(e))throw new Error("Invalid credentials policy.");if(e==="include"&&o.origin!==window.location.origin&&!n.allowCrossOriginCredentials)throw new Error("Cross-origin credentials require explicit authorization.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const s={...w,"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":p,"X-Requested-With":"UAuth"};let u=null;if(i!==null&&i!==undefined){if(r==="GET")throw new Error("GET requests cannot contain a body.");if(c==="json")s["Content-Type"]="application/json",u=JSON.stringify(i);else if(c==="form"){s["Content-Type"]="application/x-www-form-urlencoded";u=new URLSearchParams;for(const[n,t]of Object.entries(i))u.append(n,t??"")}else throw new Error("Unsupported content type.");}let t;try{t=await fetch(o.href,{method:r,credentials:e,redirect:a?"follow":"error",headers:s,body:u})}catch(l){if(l instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw l;}let f=null;if(t.status!==204&&t.status!==205){const n=await t.text();if(n)try{f=JSON.parse(n)}catch{f=n}}if(v&&t.ok){const n=f?.redirectUrl;if(typeof n=="string"&&n.length>0){const t=new URL(n,window.location.origin);if(!["http:","https:"].includes(t.protocol))throw new Error("Invalid logout redirect protocol.");window.location.assign(t.href)}}return{retryAfter:t.headers.get("Retry-After"),ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:f}};window.uauth.resourceRequest=async function(n){const{url:o,baseAddress:h,method:v="GET",payload:f=null,clientProfile:y="",credentials:p="Auto",requestId:u}=n;if(!o||typeof o!="string")throw new Error("Resource request URL is required.");const i=h?new URL(h):new URL(window.location.origin+"/");if(!["http:","https:"].includes(i.protocol))throw new Error("Invalid Resource API protocol.");if(i.username||i.password||i.search||i.hash)throw new Error("Invalid Resource API base address.");const t=new URL(o,i);if(t.origin!==i.origin)throw new Error("Resource request target differs from configured API origin.");if(t.username||t.password)throw new Error("Resource URL cannot contain credentials.");if(!["http:","https:"].includes(t.protocol))throw new Error("Unsupported Resource API protocol.");const s=v.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(s))throw new Error("Unsupported Resource API method.");if(s==="GET"&&f!==null)throw new Error("GET requests cannot contain a body.");let r;switch(p.toLowerCase()){case"auto":r=t.origin===window.location.origin?"same-origin":"include";break;case"sameorigin":r="same-origin";break;case"include":r="include";break;case"omit":r="omit";break;default:throw new Error("Invalid Resource credentials mode.");}if(r==="include"&&t.protocol!=="https:"&&!["localhost","127.0.0.1","[::1]"].includes(t.hostname))throw new Error("Cross-origin credential requests require HTTPS.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const c={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":y,"X-Requested-With":"UAuth"};let l=null;f!==null&&f!==undefined&&(c["Content-Type"]="application/json",l=JSON.stringify(f));const a=new AbortController;if(u){if(window.uauth.resourceRequests.has(u))throw new Error("Duplicate Resource request ID.");window.uauth.resourceRequests.set(u,a)}try{const n=await fetch(t.href,{method:s,credentials:r,redirect:"error",headers:c,body:l,signal:a.signal});let i=null;if(n.status!==204&&n.status!==205){const t=await n.text();t.length>0&&(i=t)}return{retryAfter:n.headers.get("Retry-After"),ok:n.ok,status:n.status,body:i,contentType:n.headers.get("Content-Type")}}catch(e){if(e?.name==="AbortError")throw e;if(e instanceof TypeError)return{ok:!1,status:0,body:null,contentType:null};throw e;}finally{u&&window.uauth.resourceRequests.delete(u)}};window.uauth.resourceRequests=new Map;window.uauth.cancelResourceRequest=function(n){const t=window.uauth.resourceRequests.get(n);return t?(t.abort(),!0):!1};window.uauth.validateAuthUrl=function(n,t=null){if(!n||typeof n!="string")throw new Error("Authentication URL is required.");const r=t?new URL(t,window.location.origin):new URL(window.location.origin),i=new URL(n,document.baseURI);if(!["http:","https:"].includes(r.protocol))throw new Error("Unsupported trusted origin protocol.");if(!["http:","https:"].includes(i.protocol))throw new Error("Unsupported authentication URL protocol.");if(r.username||r.password)throw new Error("Trusted origin cannot contain credentials.");if(i.username||i.password)throw new Error("Authentication URL cannot contain credentials.");if(i.origin!==r.origin)throw new Error("Authentication request target differs from the trusted UAuthHub origin.");return i.href};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs index df208996..1b8fd30b 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs @@ -1,4 +1,7 @@ using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using System.Net; using CodeBeam.UltimateAuth.Core.Abstractions; using CodeBeam.UltimateAuth.Users.Contracts; @@ -7,6 +10,7 @@ namespace CodeBeam.UltimateAuth.Client.Authentication; internal sealed class UAuthStateManager : IUAuthStateManager, IDisposable { private Task? _ensureTask; + private DateTimeOffset _validationNotBefore; private readonly object _ensureLock = new(); private readonly IUAuthClient _client; @@ -26,6 +30,8 @@ public UAuthStateManager(IUAuthClient client, IUAuthClientEvents events, IClock public Task EnsureAsync(bool force = false, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + if (_clock.UtcNow < _validationNotBefore) return Task.CompletedTask; if (!force && State.IsAuthenticated && !State.IsStale) return Task.CompletedTask; @@ -59,6 +65,10 @@ private async Task EnsureInternalAsync(CancellationToken ct) State.ApplySnapshot(result.Snapshot, _clock.UtcNow); } + catch (UAuthTransportException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests) + { + _validationNotBefore = UAuthRateLimit.NotBefore(_clock.UtcNow, ex.RetryAfter); + } finally { lock (_ensureLock) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs index 82206fe2..dfca87be 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs @@ -7,6 +7,8 @@ namespace CodeBeam.UltimateAuth.Client.Contracts; /// public sealed record RefreshResult { + public string? ErrorCode { get; init; } + public TimeSpan? RetryAfter { get; init; } /// /// Gets a value indicating whether the refresh operation completed successfully. /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthResourceTransportResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthResourceTransportResult.cs index 73defa21..b6fbfb99 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthResourceTransportResult.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthResourceTransportResult.cs @@ -10,6 +10,10 @@ public sealed class UAuthResourceTransportResult [JsonPropertyName("ok")] public bool Ok { get; init; } + /// Raw Retry-After header, when exposed by the server/CORS policy. + [JsonPropertyName("retryAfter")] + public string? RetryAfter { get; init; } + [JsonPropertyName("status")] public int Status { get; init; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs index 0e87ba67..4381fd98 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs @@ -18,6 +18,10 @@ public sealed class UAuthTransportResult [JsonPropertyName("ok")] public bool Ok { get; init; } + /// Raw Retry-After header, when exposed by the server/CORS policy. + [JsonPropertyName("retryAfter")] + public string? RetryAfter { get; init; } + /// /// Gets the HTTP status code returned by the request. /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs index b41c0f6a..2c123769 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs @@ -5,6 +5,7 @@ namespace CodeBeam.UltimateAuth.Client.Errors; public sealed class UAuthTransportException : UAuthClientException { public HttpStatusCode? StatusCode { get; } + public TimeSpan? RetryAfter { get; init; } public UAuthTransportException(string message) : base("transport_error", message) { @@ -14,12 +15,12 @@ public UAuthTransportException(string message, Exception? inner) : base("transpo { } - public UAuthTransportException(string message, HttpStatusCode statusCode) : base("transport_error", message) + public UAuthTransportException(string message, HttpStatusCode statusCode) : base(statusCode == HttpStatusCode.TooManyRequests ? "rate_limited" : "transport_error", message) { StatusCode = statusCode; } - public UAuthTransportException(string message, HttpStatusCode statusCode, Exception? inner) : base("transport_error", message, inner) + public UAuthTransportException(string message, HttpStatusCode statusCode, Exception? inner) : base(statusCode == HttpStatusCode.TooManyRequests ? "rate_limited" : "transport_error", message, inner) { StatusCode = statusCode; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs index b1c7d47b..521adeae 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs @@ -28,6 +28,10 @@ public interface IUAuthRequestClient Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + /// Observes logout before following successful browser redirects. Existing adapters may delegate to their form transport. + Task SendFormAsync(string endpoint, IDictionary? form, bool navigateOnSuccess, CancellationToken ct = default) + => SendFormAsync(endpoint, form, ct); + /// /// Sends a JSON payload to the specified endpoint and returns the result of the operation. /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRateLimit.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRateLimit.cs new file mode 100644 index 00000000..32923b4e --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthRateLimit.cs @@ -0,0 +1,34 @@ +using System.Globalization; +using System.Net; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class UAuthRateLimit +{ + internal static TimeSpan? ParseRetryAfter(string? header, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(header)) return null; + header = header.Trim(); + if (long.TryParse(header, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)) + return seconds <= TimeSpan.MaxValue.Ticks / TimeSpan.TicksPerSecond ? TimeSpan.FromTicks(seconds * TimeSpan.TicksPerSecond) : null; + if (DateTimeOffset.TryParseExact(header, "r", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + return date > now ? date - now : TimeSpan.Zero; + return null; + } + + internal static DateTimeOffset NotBefore(DateTimeOffset now, TimeSpan? retryAfter) + { + var delay = retryAfter ?? TimeSpan.FromSeconds(30); + if (delay < TimeSpan.FromSeconds(1)) delay = TimeSpan.FromSeconds(1); + return delay.Ticks > DateTimeOffset.MaxValue.Ticks - now.UtcTicks ? DateTimeOffset.MaxValue : now.Add(delay); + } + + internal static UAuthTransportException Exception(string? header) => new("Temporarily rate limited.", HttpStatusCode.TooManyRequests) + { RetryAfter = ParseRetryAfter(header, DateTimeOffset.UtcNow) }; + + internal static UAuthProblem Problem(string? header) => new() + { Status = 429, Title = "Too many requests", Detail = "rate_limited", RetryAfter = ParseRetryAfter(header, DateTimeOffset.UtcNow) }; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs index 17bc8eef..0ca40000 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -94,6 +94,7 @@ private static void EnsureTransport(UAuthTransportResult raw) private static UAuthProblem? CreateProblem(UAuthTransportResult raw) { + if (raw.Status == 429) return UAuthRateLimit.Problem(raw.RetryAfter); var parsed = TryDeserializeProblem(raw); if (parsed is null) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index 41ede98f..d61535df 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -27,6 +27,8 @@ internal class UAuthFlowClient : IFlowClient private readonly IReturnUrlProvider _returnUrlProvider; private readonly UAuthClientOptions _options; private readonly UAuthClientDiagnostics _diagnostics; + private DateTimeOffset _refreshNotBefore; + private RefreshResult? _refreshThrottle; public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IClientDeviceProvider clientDeviceProvider, IReturnUrlProvider returnUrlProvider, IOptions options, UAuthClientDiagnostics diagnostics) { @@ -65,6 +67,7 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi { var raw = await _post.SendJsonAsync(tryUrl, payload); + if (raw.Status == 429) return new TryLoginResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = UAuthRateLimit.ParseRetryAfter(raw.RetryAfter, DateTimeOffset.UtcNow) }; EnsureValidHttpStatus(raw); if (raw.Status is >= 400 and < 500) @@ -102,7 +105,10 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi case UAuthSubmitMode.TryAndCommit: default: { - var result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); + TryLoginResult result; + try { result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); } + catch (UAuthTransportException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests) + { return new TryLoginResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = ex.RetryAfter }; } if (result is null) throw new UAuthProtocolException("Invalid try-login result."); @@ -115,7 +121,11 @@ public async Task TryLoginAsync(LoginRequest request, UAuthSubmi public async Task LogoutAsync() { var url = Url(_options.Endpoints.Logout); - await _post.NavigateAsync(url); + var raw = await _post.SendFormAsync(url, null, navigateOnSuccess: true); + EnsureValidHttpStatus(raw); + if (raw.Status is < 200 or >= 300) + throw new UAuthTransportException("Server logout did not succeed.", (HttpStatusCode)raw.Status); + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); } @@ -126,7 +136,24 @@ public async Task RefreshAsync(bool isAuto = false) _diagnostics.MarkManualRefresh(); } + var now = DateTimeOffset.UtcNow; + if (_refreshThrottle is not null && now < _refreshNotBefore) + return _refreshThrottle with { RetryAfter = _refreshNotBefore - now }; var raw = await _post.SendFormAsync(Url(_options.Endpoints.Refresh)); + if (raw.Status == 429) + { + _refreshThrottle = new RefreshResult + { + Status = 429, + IsSuccess = false, + Outcome = RefreshOutcome.None, + ErrorCode = "rate_limited", + RetryAfter = UAuthRateLimit.ParseRetryAfter(raw.RetryAfter, DateTimeOffset.UtcNow) + }; + _refreshNotBefore = UAuthRateLimit.NotBefore(DateTimeOffset.UtcNow, _refreshThrottle.RetryAfter); + return _refreshThrottle; + } + _refreshThrottle = null; // 401 is a normal authentication outcome for refresh. if (raw.Status == 401) @@ -197,6 +224,7 @@ public async Task ValidateAsync() var url = Url(_options.Endpoints.Validate); var raw = await _post.SendFormAsync(url); + if (raw.Status == 429) throw UAuthRateLimit.Exception(raw.RetryAfter); if (raw.Status == 0) throw new UAuthTransportException("Network error during validation."); @@ -335,6 +363,7 @@ public async Task TryCompletePkceLoginAsync(PkceCompleteRequ case UAuthSubmitMode.TryOnly: { var raw = await _post.SendJsonAsync(tryUrl, request); + if (raw.Status == 429) return new TryPkceLoginResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = UAuthRateLimit.ParseRetryAfter(raw.RetryAfter, DateTimeOffset.UtcNow) }; EnsureValidHttpStatus(raw); @@ -367,7 +396,10 @@ public async Task TryCompletePkceLoginAsync(PkceCompleteRequ case UAuthSubmitMode.TryAndCommit: default: { - var result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); + TryPkceLoginResult result; + try { result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); } + catch (UAuthTransportException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests) + { return new TryPkceLoginResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = ex.RetryAfter }; } if (result is null) throw new UAuthProtocolException("Invalid PKCE try result."); @@ -530,6 +562,7 @@ private static void EnsureValidHttpStatus(UAuthTransportResult raw) if (raw is null) throw new UAuthProtocolException("Transport client returned a null response."); + if (raw.Status == 429) throw UAuthRateLimit.Exception(raw.RetryAfter); if (raw.Status == 0) throw new UAuthTransportException("Network error."); diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthResourceClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthResourceClient.cs index 5e9eb4c1..a9998c63 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthResourceClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthResourceClient.cs @@ -34,9 +34,9 @@ public async Task DeleteAsync(string endpoint, CancellationToken ct return new UAuthResult { - IsSuccess = response.Ok, + IsSuccess = (response.Ok && response.Status is >= 200 and < 300), Status = response.Status, - Problem = response.Ok ? null : ReadProblem(response) + Problem = (response.Ok && response.Status is >= 200 and < 300) ? null : ReadProblem(response) }; } @@ -46,10 +46,10 @@ private async Task> SendAsync(string method, string endpoint, return new UAuthResult { - IsSuccess = response.Ok, + IsSuccess = (response.Ok && response.Status is >= 200 and < 300), Status = response.Status, - Value = response.Ok ? ReadValue(response) : default, - Problem = response.Ok ? null : ReadProblem(response) + Value = (response.Ok && response.Status is >= 200 and < 300) ? ReadValue(response) : default, + Problem = (response.Ok && response.Status is >= 200 and < 300) ? null : ReadProblem(response) }; } @@ -63,6 +63,7 @@ private async Task> SendAsync(string method, string endpoint, private static UAuthProblem? ReadProblem(UAuthResourceTransportResult response) { + if (response.Status == 429) return UAuthRateLimit.Problem(response.RetryAfter); if (string.IsNullOrWhiteSpace(response.Body)) return null; diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/ClientRateLimitingIntegrationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/ClientRateLimitingIntegrationTests.cs new file mode 100644 index 00000000..3baccf5c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/ClientRateLimitingIntegrationTests.cs @@ -0,0 +1,139 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class ClientRateLimitingIntegrationTests +{ + [Fact] + public async Task NativeValidation429PreservesClientState_AndLaterRevocationStillClearsIt() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() { PermitLimit = 1 }; }); + var user = await factory.CreateLoginUserAsync(); + using var http = await Login(factory, user); + var transport = new HttpTransport(http); + using var services = ClientServices(factory, transport); + using var scope = services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + await manager.EnsureAsync(); + Assert.True(manager.State.IsAuthenticated); + var validatedAt = manager.State.LastValidatedAt; + await manager.EnsureAsync(force: true); + await manager.EnsureAsync(force: true); + Assert.True(manager.State.IsAuthenticated); + Assert.Equal(validatedAt, manager.State.LastValidatedAt); + Assert.Equal(2, transport.Count); + using (var serverScope = factory.Services.CreateScope()) + { + var store = serverScope.ServiceProvider.GetRequiredService().Create(TenantKey.Single); + await store.ExecuteAsync(ct => store.RevokeRootCascadeAsync(user.UserKey, factory.Clock.UtcNow, ct)); + } + factory.Clock.Advance(TimeSpan.FromMinutes(2)); + await manager.EnsureAsync(force: true); + Assert.False(manager.State.IsAuthenticated); + } + + [Fact] + public async Task NativeRefresh429PreservesCredentials_AndSuppressesImmediateRefreshLoop() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.Refresh] = new() { PermitLimit = 1 }; }); + var user = await factory.CreateLoginUserAsync(); + using var http = await Login(factory, user); + var credential = http.DefaultRequestHeaders.GetValues("Cookie").Single(); + var transport = new HttpTransport(http); + using var services = ClientServices(factory, transport); + using var scope = services.CreateScope(); + var flow = scope.ServiceProvider.GetRequiredService().Flows; + Assert.True((await flow.RefreshAsync()).IsSuccess); + var throttle = await flow.RefreshAsync(); + Assert.Equal(429, throttle.Status); + Assert.Equal("rate_limited", throttle.ErrorCode); + Assert.Equal(RefreshOutcome.None, throttle.Outcome); + Assert.Equal(credential, http.DefaultRequestHeaders.GetValues("Cookie").Single()); + await flow.RefreshAsync(isAuto: true); + Assert.Equal(2, transport.Count); + var session = await http.PostAsync("/auth/validate", null); + Assert.Equal(HttpStatusCode.OK, session.StatusCode); + Assert.True((await session.Content.ReadFromJsonAsync())!.IsValid); + } + + private static async Task Login(AuthServerFactory factory, IntegrationTestUser user) + { + var http = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + http.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + http.DefaultRequestHeaders.Add("X-UDID", "client-rate-device-123456789"); + http.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", "BlazorServer"); + var login = await http.PostAsJsonAsync("/auth/login", new { identifier = user.Identifier, secret = user.Secret }); + Assert.True(login.Headers.TryGetValues("Set-Cookie", out var cookies)); + http.DefaultRequestHeaders.Add("Cookie", string.Join("; ", cookies!.Select(c => c.Split(';')[0]))); + return http; + } + + private static ServiceProvider ClientServices(AuthServerFactory factory, HttpTransport transport) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddUltimateAuthClient(o => { o.ClientProfile = UAuthClientProfile.BlazorServer; o.AutoDetectClientProfile = false; }); + services.AddSingleton(); + services.AddSingleton(factory.Clock); + services.AddSingleton(transport); + services.AddSingleton(transport); + services.AddSingleton(transport); + services.AddSingleton(transport); + return services.BuildServiceProvider(); + } + + // Thin HTTP test adapter: public SDK contracts against the real auth host and native middleware. + private sealed class HttpTransport(HttpClient http) : IUAuthRequestClient, IUAuthResourceRequestClient, IClientDeviceProvider, IReturnUrlProvider + { + internal int Count { get; private set; } + public Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) => throw new NotSupportedException(); + public Task TryAndCommitAsync(string tryEndpoint, string commitEndpoint, object request, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetAsync() => Task.FromResult(DeviceContext.Anonymous()); + public string GetCurrentUrl() => "/"; + public async Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + Count++; + using var response = await http.PostAsync(endpoint, form is null ? null : new FormUrlEncodedContent(form), ct); + return await Read(response, ct); + } + public async Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default) + { + Count++; + using var response = await http.PostAsJsonAsync(endpoint, payload, ct); + return await Read(response, ct); + } + private static async Task Read(HttpResponseMessage response, CancellationToken ct) + { + var body = await response.Content.ReadAsStringAsync(ct); + return new() + { + Status = (int)response.StatusCode, + Ok = response.IsSuccessStatusCode, + RetryAfter = response.Headers.TryGetValues("Retry-After", out var retry) ? retry.First() : null, + RefreshOutcome = response.Headers.TryGetValues("X-UAuth-Refresh", out var outcome) ? outcome.First() : null, + Body = string.IsNullOrWhiteSpace(body) ? null : JsonSerializer.Deserialize(body) + }; + } + public Task SendAsync(string method, string endpoint, object? payload = null, CancellationToken ct = default) => throw new NotSupportedException(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs index 025b4dec..a07c17cd 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/Infrastructure/AuthServerFactory.cs @@ -61,6 +61,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.AddSingleton(Clock); + // Business-flow tests share a host; limiter contracts use isolated hosts. + services.PostConfigure(options => options.RateLimiting.Enabled = false); + if (_configureServer is not null) { services.PostConfigure(options => _configureServer(options)); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingFlowTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingFlowTests.cs new file mode 100644 index 00000000..d5883975 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingFlowTests.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Net.Http.Json; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class RateLimitingFlowTests +{ + [Fact] + public async Task RejectedLoginDoesNotLockAccount_AndRejectedLogoutDoesNotRevokeSession() + { + using var factory = AuthServerFactory.Create(o => + { + o.RateLimiting.Enabled = true; + o.RateLimiting.Login.PermitLimit = 2; + o.RateLimiting.Endpoints[UAuthEndpoint.Logout] = new() { PermitLimit = 1 }; + o.Login.MaxFailedAttempts = 1; + }); + var user = await factory.CreateLoginUserAsync(); + HttpClient Client(string device) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions + { AllowAutoRedirect = false, HandleCookies = false }); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", device); + return client; + } + using var first = Client("rate-limit-flow-first-123456"); + using var second = Client("rate-limit-flow-second-123456"); + foreach (var client in new[] { first, second }) + { + var login = await client.PostAsJsonAsync("/auth/login", new { identifier = user.Identifier, secret = user.Secret }); + Assert.True(login.Headers.TryGetValues("Set-Cookie", out var cookies)); + client.DefaultRequestHeaders.Add("Cookie", cookies!.First()); + } + var rejected = await first.PostAsJsonAsync("/auth/login", new { identifier = user.Identifier, secret = "wrong" }); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.False(rejected.Headers.Contains("Set-Cookie")); + using (var scope = factory.Services.CreateScope()) + { + var security = scope.ServiceProvider.GetRequiredService(); + var factor = await security.GetOrCreateFactorAsync(TenantKey.Single, user.UserKey, CredentialType.Password); + Assert.Equal(0, factor.FailedAttempts); + } + Assert.NotEqual(HttpStatusCode.TooManyRequests, (await first.PostAsync("/auth/logout", null)).StatusCode); + // Two validated sessions of the same actor share logout capacity. + var logout = await second.PostAsync("/auth/logout", null); + Assert.Equal(HttpStatusCode.TooManyRequests, logout.StatusCode); + Assert.False(logout.Headers.Contains("Set-Cookie")); + // The rejected logout did not revoke the second session. + var validation = await second.PostAsync("/auth/validate", null); + Assert.Equal(HttpStatusCode.OK, validation.StatusCode); + var result = await validation.Content.ReadFromJsonAsync(); + Assert.True(result?.IsValid); + Assert.NotNull(result!.Snapshot); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingProofFlowTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingProofFlowTests.cs new file mode 100644 index 00000000..2b9910fd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingProofFlowTests.cs @@ -0,0 +1,164 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using CodeBeam.UltimateAuth.Tests.Integration.Infrastructure; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public sealed class RateLimitingProofFlowTests +{ + private static readonly TimeSpan Window = TimeSpan.FromSeconds(2); + + [Fact] + public async Task PkceCompletion_RejectedRequestPreservesAuthorizationProof() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Login = new() { PermitLimit = 1, Window = Window }; }); + var user = await factory.CreateLoginUserAsync(); + using var client = Client(factory); + static string Encode(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + var verifier = Encode(RandomNumberGenerator.GetBytes(32)); + var authorize = await client.PostAsync("/auth/pkce/authorize", new FormUrlEncodedContent(new Dictionary + { ["code_challenge"] = Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))), ["challenge_method"] = "S256", ["redirect_uri"] = "/callback" })); + Assert.Equal(HttpStatusCode.OK, authorize.StatusCode); + var proof = await authorize.Content.ReadFromJsonAsync(); + Assert.NotNull(proof); + // Malformed payload consumes request quota without supplying the valid authorization proof. + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync("/auth/pkce/complete", new FormUrlEncodedContent(new Dictionary()))).StatusCode); + Task Complete() => client.PostAsync("/auth/pkce/complete", new FormUrlEncodedContent(new Dictionary + { + ["authorization_code"] = proof.AuthorizationCode, + ["code_verifier"] = verifier, + ["Identifier"] = user.Identifier, + ["Secret"] = user.Secret, + ["return_url"] = "/callback" + })); + var rejected = await Complete(); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.False(rejected.Headers.Contains("Set-Cookie")); + var completed = await AfterReplenishment(Complete); + Assert.NotEqual(HttpStatusCode.TooManyRequests, completed.StatusCode); + Assert.True(completed.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task Refresh_RejectedRequestPreservesRefreshToken_ThenSameTokenRotates() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.Refresh] = new() { PermitLimit = 1, Window = Window }; }); + var user = await factory.CreateLoginUserAsync(); + using var client = Client(factory); + client.DefaultRequestHeaders.Add("X-UAuth-ClientProfile", "BlazorWasm"); + var login = await client.PostAsJsonAsync("/auth/login", new { identifier = user.Identifier, secret = user.Secret }); + var cookies = Cookies(login); + var session = cookies.Split("; ").Single(c => c.StartsWith("uas=")); + var refresh = cookies.Split("; ").Single(c => c.StartsWith("uar="))[4..]; + using var scope = factory.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService().Create(TenantKey.Single); + var hash = scope.ServiceProvider.GetRequiredService().Hash(refresh); + var before = await store.FindByHashAsync(hash); + Assert.NotNull(before); + client.DefaultRequestHeaders.Add("Cookie", session); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.PostAsync("/auth/refresh", null)).StatusCode); + client.DefaultRequestHeaders.Remove("Cookie"); + client.DefaultRequestHeaders.Add("Cookie", cookies); + var rejected = await client.PostAsync("/auth/refresh", null); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.False(rejected.Headers.Contains("Set-Cookie")); + Assert.Equal(before, await store.FindByHashAsync(hash)); + var renewed = await AfterReplenishment(() => client.PostAsync("/auth/refresh", null)); + Assert.Equal(HttpStatusCode.NoContent, renewed.StatusCode); + Assert.True(renewed.Headers.Contains("Set-Cookie")); + Assert.False((await store.FindByHashAsync(hash))!.IsActive(factory.Clock.UtcNow)); + } + + [Fact] + public async Task Recovery_RejectedRequestPreservesResetProof_ThenSameProofChangesPassword() + { + var notifier = new RecordingResetNotifier(); + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.CredentialResetCompleteAnonymous] = new() { PermitLimit = 1, Window = Window }; }, services => + { services.RemoveAll(); services.AddSingleton(notifier); }); + var user = await factory.CreateLoginUserAsync(); + using var client = Client(factory); + var begin = await client.PostAsJsonAsync("/auth/me/credentials/reset/begin", new BeginResetCredentialRequest + { Identifier = user.Identifier, CredentialType = CredentialType.Password, ResetCodeType = ResetCodeType.Token }); + Assert.Equal(HttpStatusCode.OK, begin.StatusCode); + Assert.NotNull(notifier.Last); + // Consume quota using an unknown account, never presenting the issued proof. + await client.PostAsJsonAsync("/auth/me/credentials/reset/complete", new CompleteResetCredentialRequest + { Identifier = "unknown-account", CredentialType = CredentialType.Password, ResetToken = "invalid", NewSecret = "Changed-password-123!" }); + var request = new CompleteResetCredentialRequest + { Identifier = user.Identifier, CredentialType = CredentialType.Password, ResetToken = notifier.Last.Token, NewSecret = "Changed-password-123!" }; + Assert.Equal(HttpStatusCode.TooManyRequests, (await client.PostAsJsonAsync("/auth/me/credentials/reset/complete", request)).StatusCode); + var completed = await AfterReplenishment(() => client.PostAsJsonAsync("/auth/me/credentials/reset/complete", request)); + Assert.Equal(HttpStatusCode.OK, completed.StatusCode); + var login = await client.PostAsJsonAsync("/auth/login", new { identifier = user.Identifier, secret = request.NewSecret }); + Assert.True(login.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task AuthenticatedUsersOnSameConnectionIpHaveIndependentValidationBudgets() + { + using var factory = AuthServerFactory.Create(o => + { o.RateLimiting.Enabled = true; o.RateLimiting.Endpoints[UAuthEndpoint.Validate] = new() { PermitLimit = 1 }; }); + using var first = Client(factory); + using var second = Client(factory); + foreach (var client in new[] { first, second }) + { + var user = await factory.CreateLoginUserAsync(); + var login = await client.PostAsJsonAsync("/auth/login", new { identifier = user.Identifier, secret = user.Secret }); + client.DefaultRequestHeaders.Add("Cookie", Cookies(login)); + var validation = await client.PostAsync("/auth/validate", null); + Assert.Equal(HttpStatusCode.OK, validation.StatusCode); + Assert.True((await validation.Content.ReadFromJsonAsync())!.IsValid); + Assert.Equal(HttpStatusCode.TooManyRequests, (await client.PostAsync("/auth/validate", null)).StatusCode); + } + } + + private static async Task AfterReplenishment(Func> send) + { + // Native limiter time is independent of the authentication test clock. Its heartbeat + // can replenish segments later than the nominal window, so wait on observable behavior. + var elapsed = System.Diagnostics.Stopwatch.StartNew(); + while (true) + { + await Task.Delay(100); + var response = await send(); + if (response.StatusCode != HttpStatusCode.TooManyRequests || elapsed.Elapsed > TimeSpan.FromSeconds(10)) + return response; + response.Dispose(); + } + } + + private static HttpClient Client(AuthServerFactory factory) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + client.DefaultRequestHeaders.Add("X-UDID", "rate-proof-device-1234567890"); + return client; + } + private static string Cookies(HttpResponseMessage response) + { + Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies)); + return string.Join("; ", cookies!.Select(c => c.Split(';')[0])); + } + private sealed class RecordingResetNotifier : ICredentialResetNotifier + { + internal CredentialResetNotification? Last { get; private set; } + public Task NotifyAsync(CredentialResetNotification notification, CancellationToken ct = default) + { Last = notification; return Task.CompletedTask; } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs new file mode 100644 index 00000000..d1a92bee --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/RateLimitingTests.cs @@ -0,0 +1,389 @@ +using System.Net; +using System.Threading.RateLimiting; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class RateLimitingTests +{ + [Fact] + public async Task LoginAliasesShareBudget_AndMaintenanceIsIndependent() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + foreach (var route in new[] { "/auth/try-login", "/auth/pkce/complete", "/auth/pkce/try-complete" }) + { + var response = await host.Post(route); + Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode); + if (response.Headers.RetryAfter is not null) + Assert.True(response.Headers.RetryAfter.Delta > TimeSpan.Zero); + Assert.True(response.Headers.CacheControl?.NoStore); + Assert.Contains("rate_limited", await response.Content.ReadAsStringAsync()); + } + foreach (var route in new[] { "/auth/validate", "/auth/refresh", "/auth/logout" }) + Assert.Equal(HttpStatusCode.NoContent, (await host.Post(route)).StatusCode); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, true, true)] + public async Task HelperOptInAndOptOut(bool aspNet, bool enabled, bool throttled) + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, aspNet, enabled); + await host.Post("/auth/login"); + Assert.Equal(throttled ? HttpStatusCode.TooManyRequests : HttpStatusCode.NoContent, + (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task DefaultHelperAndRepeatedHelper_DoNotConsumeTwice() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 2, repeat: true); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task DisableAndEndpointOverride_DoNotDisableApplicationGlobalLimiter() + { + await using var host = await Host.Create(o => o.RateLimiting.Enabled = false, native: o => + { + o.GlobalLimiter = PartitionedRateLimiter.Create(_ => + RateLimitPartition.GetSlidingWindowLimiter("global", _ => new() + { PermitLimit = 1, Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 6 })); + o.RejectionStatusCode = 418; + }); + await host.Post("/auth/login"); + Assert.Equal(418, (int)(await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task ApplicationGroupPolicyWins_AndGenericNamesDoNotCollide() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, + native: o => o.AddFixedWindowLimiter("Sensitive", x => + { x.PermitLimit = 3; x.Window = TimeSpan.FromMinutes(1); }), applicationPolicy: true); + for (var i = 0; i < 3; i++) + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.ServiceUnavailable, (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task EndpointOverrideAndAnonymousIpIsolation() + { + await using var host = await Host.Create(o => o.RateLimiting.Endpoints[UAuthEndpoint.Login] = new() { PermitLimit = 1 }); + await host.Post("/auth/login"); + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Post("/auth/login", forwarded: "203.0.113.9")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login", ip: "192.0.2.2")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/try-login")).StatusCode); + } + + [Fact] + public async Task EveryMappedEndpointHasOneClassification() + { + await using var host = await Host.Create(); + var routes = ((IEndpointRouteBuilder)host.App).DataSources.SelectMany(s => s.Endpoints).ToArray(); + // Reviewed route/identity/group contract, independent of the runtime classification lookup. + const string expected = """ +/auth/admin/authorization/roles/create|CreateRole|Standard +/auth/admin/authorization/roles/query|QueryRoles|Standard +/auth/admin/authorization/roles/{roleId}/delete|DeleteRole|Standard +/auth/admin/authorization/roles/{roleId}/permissions|SetRolePermissions|Standard +/auth/admin/authorization/roles/{roleId}/rename|RenameRole|Standard +/auth/admin/authorization/users/{userKey}/roles/assign|AssignRole|Standard +/auth/admin/authorization/users/{userKey}/roles/get|GetUserRoles|Standard +/auth/admin/authorization/users/{userKey}/roles/remove|RemoveRole|Standard +/auth/admin/users/create|UserCreateAdmin|Standard +/auth/admin/users/get-by-keys|GetUsersByKeys|Standard +/auth/admin/users/query|QueryUsers|Standard +/auth/admin/users/{userKey}/credentials/add|CredentialAddAdmin|Standard +/auth/admin/users/{userKey}/credentials/change|CredentialChangeAdmin|Standard +/auth/admin/users/{userKey}/credentials/delete|CredentialDeleteAdmin|Standard +/auth/admin/users/{userKey}/credentials/reset/begin|CredentialResetBeginAdmin|Sensitive +/auth/admin/users/{userKey}/credentials/reset/complete|CredentialResetCompleteAdmin|Sensitive +/auth/admin/users/{userKey}/credentials/revoke|CredentialRevokeAdmin|Standard +/auth/admin/users/{userKey}/delete|UserDeleteAdmin|Standard +/auth/admin/users/{userKey}/identifiers/add|AddUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/delete|DeleteUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/get|GetUserIdentifiers|Standard +/auth/admin/users/{userKey}/identifiers/set-primary|SetPrimaryUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/unset-primary|UnsetPrimaryUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/update|UpdateUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/identifiers/verify|VerifyUserIdentifierAdmin|Standard +/auth/admin/users/{userKey}/logout-all|LogoutAllAdmin|Standard +/auth/admin/users/{userKey}/logout-device|LogoutDeviceAdmin|Standard +/auth/admin/users/{userKey}/logout-others|LogoutOthersAdmin|Standard +/auth/admin/users/{userKey}/profile/create|CreateProfileAdmin|Standard +/auth/admin/users/{userKey}/profile/delete|DeleteProfileAdmin|Standard +/auth/admin/users/{userKey}/profile/get|ProfileGetAdmin|Standard +/auth/admin/users/{userKey}/profile/update|ProfileUpdateAdmin|Standard +/auth/admin/users/{userKey}/sessions/chains/{chainId}/revoke|RevokeUserChain|Standard +/auth/admin/users/{userKey}/sessions/chains/{chainId}|GetUserChainDetail|Standard +/auth/admin/users/{userKey}/sessions/chains|GetUserChains|Standard +/auth/admin/users/{userKey}/sessions/revoke-all|RevokeAllChains|Standard +/auth/admin/users/{userKey}/sessions/revoke-root|RevokeRoot|Standard +/auth/admin/users/{userKey}/sessions/{sessionId}/revoke|RevokeUserSession|Standard +/auth/admin/users/{userKey}/status|ChangeStatusAdmin|Standard +/auth/login|Login|Sensitive +/auth/logout|Logout|Frequent +/auth/me/authorization/check|AuthorizationCheck|Frequent +/auth/me/authorization/permissions/get|GetMyPermissions|Standard +/auth/me/authorization/roles/get|GetMyRoles|Standard +/auth/me/credentials/add|CredentialAddSelf|Sensitive +/auth/me/credentials/change|CredentialChangeSelf|Sensitive +/auth/me/credentials/reset/begin|CredentialResetBeginAnonymous|Sensitive +/auth/me/credentials/reset/complete|CredentialResetCompleteAnonymous|Sensitive +/auth/me/credentials/revoke|CredentialRevokeSelf|Standard +/auth/me/delete|UserDeleteSelf|Standard +/auth/me/identifiers/add|AddUserIdentifierSelf|Sensitive +/auth/me/identifiers/check-availability|CheckIdentifierAvailability|Sensitive +/auth/me/identifiers/delete|DeleteUserIdentifierSelf|Sensitive +/auth/me/identifiers/get|GetMyIdentifiers|Standard +/auth/me/identifiers/set-primary|SetPrimaryUserIdentifierSelf|Sensitive +/auth/me/identifiers/unset-primary|UnsetPrimaryUserIdentifierSelf|Sensitive +/auth/me/identifiers/update|UpdateUserIdentifierSelf|Sensitive +/auth/me/identifiers/verify|VerifyUserIdentifierSelf|Sensitive +/auth/me/logout-all|LogoutAllSelf|Standard +/auth/me/logout-device|LogoutDeviceSelf|Standard +/auth/me/logout-others|LogoutOthersSelf|Standard +/auth/me/profile/create|CreateProfileSelf|Standard +/auth/me/profile/delete|DeleteProfileSelf|Standard +/auth/me/profile/get|ProfileGetSelf|Standard +/auth/me/profile/update|ProfileUpdateSelf|Standard +/auth/me/sessions/chains/{chainId}/revoke|RevokeMyChain|Standard +/auth/me/sessions/chains/{chainId}|GetMyChainDetail|Standard +/auth/me/sessions/chains|GetMyChains|Standard +/auth/me/sessions/revoke-all|RevokeAllMyChains|Standard +/auth/me/sessions/revoke-others|RevokeOtherChains|Standard +/auth/me/status|ChangeStatusSelf|Standard +/auth/pkce/authorize|PkceAuthorize|Sensitive +/auth/pkce/complete|PkceComplete|Sensitive +/auth/pkce/try-complete|PkceTryComplete|Sensitive +/auth/refresh|Refresh|Frequent +/auth/try-login|TryLogin|Sensitive +/auth/uauthhub/entry|HubEntry|Sensitive +/auth/users/create|UserCreateAnonymous|Sensitive +/auth/validate|Validate|Frequent +"""; + var actual = routes.Cast().Select(endpoint => + { + var metadata = Assert.Single(endpoint.Metadata, m => m.GetType().Name == "UAuthRateLimitMetadata"); + var type = metadata.GetType(); + Assert.Equal(new[] { "POST" }, endpoint.Metadata.GetMetadata()!.HttpMethods); + return $"{endpoint.RoutePattern.RawText}|{type.GetProperty("Endpoint")!.GetValue(metadata)}|{type.GetProperty("Group")!.GetValue(metadata)}"; + }).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + Assert.Equal(expected.Split('\n').Select(x => x.Trim()).OrderBy(x => x, StringComparer.Ordinal), actual); + } + + [Fact] + public async Task TrustedForwardingSeparatesClients_UnknownProxiesCannotRotateHeaders() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, trustedProxy: true); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login", forwarded: "203.0.113.1")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login", forwarded: "203.0.113.2")).StatusCode); + await host.Post("/auth/login", ip: "192.0.2.2", forwarded: "203.0.113.3"); + Assert.Equal(HttpStatusCode.TooManyRequests, + (await host.Post("/auth/login", ip: "192.0.2.2", forwarded: "203.0.113.4")).StatusCode); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NamedApplicationPolicyUsesEffectiveMiddlewareRejection(bool explicitOptions) + { + var options = new RateLimiterOptions(); + options.AddFixedWindowLimiter("application-login", r => { r.PermitLimit = 1; r.Window = TimeSpan.FromMinutes(1); }); + options.OnRejected = (rejected, ct) => { rejected.HttpContext.Response.StatusCode = 418; return ValueTask.CompletedTask; }; + await using var host = await Host.Create(o => o.RateLimiting.Login.PolicyName = "application-login", + native: explicitOptions ? null : o => + { + o.AddFixedWindowLimiter("application-login", r => { r.PermitLimit = 1; r.Window = TimeSpan.FromMinutes(1); }); + o.OnRejected = options.OnRejected; + }, middlewareOptions: explicitOptions ? options : null); + await host.Post("/auth/login"); + Assert.Equal(418, (int)(await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task BuiltInPolicyOwnsRejectionEvenWithExplicitMiddlewareOptions() + { + var options = new RateLimiterOptions { OnRejected = (_, _) => throw new InvalidOperationException("Global callback must not replace endpoint policy rejection.") }; + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, middlewareOptions: options); + await host.Post("/auth/login"); + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task ChildPipelineDoesNotInheritInstallation_AndDeduplicatesItsOwnCalls() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1); + var endpoint = ((IEndpointRouteBuilder)host.App).DataSources.SelectMany(s => s.Endpoints) + .OfType().Single(e => e.RoutePattern.RawText == "/auth/login"); + var child = ((IApplicationBuilder)host.App).New(); + child.UseUltimateAuth(useRateLimiting: true); + child.UseUltimateAuth(useRateLimiting: true); + child.Run(context => { context.Response.StatusCode = 204; return Task.CompletedTask; }); + var pipeline = child.Build(); + async Task Send() + { + using var scope = host.App.Services.CreateScope(); + var context = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; + context.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1"); + context.Response.Body = new MemoryStream(); + context.SetEndpoint(endpoint); + await pipeline(context); + return context.Response.StatusCode; + } + Assert.Equal(204, await Send()); + Assert.Equal(429, await Send()); + } + + [Fact] + public void MiddlewareHelperSignaturesAndCallFormsRemainAvailable() + { + var type = typeof(UltimateAuthApplicationBuilderExtensions); + + Assert.NotNull(type.GetMethod( + "UseUltimateAuth", + new[] { typeof(IApplicationBuilder) })); + + Assert.NotNull(type.GetMethod( + "UseUltimateAuth", + new[] { typeof(IApplicationBuilder), typeof(bool) })); + + var method = type.GetMethod( + "UseUltimateAuthWithAspNetCore", + new[] + { + typeof(IApplicationBuilder), + typeof(bool?), + typeof(bool) + }); + + Assert.NotNull(method); + + var parameters = method.GetParameters(); + + Assert.True(parameters[1].IsOptional); + Assert.Null(parameters[1].DefaultValue); + + Assert.True(parameters[2].IsOptional); + Assert.Equal(true, parameters[2].DefaultValue); + + // Compile-time coverage for the supported public API. + static void Calls(IApplicationBuilder app) + { + app.UseUltimateAuth(); + app.UseUltimateAuth(true); + app.UseUltimateAuth(useRateLimiting: false); + + app.UseUltimateAuthWithAspNetCore(); + app.UseUltimateAuthWithAspNetCore(false); + app.UseUltimateAuthWithAspNetCore(null); + app.UseUltimateAuthWithAspNetCore(enableCors: true); + app.UseUltimateAuthWithAspNetCore(null, false); + app.UseUltimateAuthWithAspNetCore( + enableCors: null, + useRateLimiting: false); + } + + Action compiled = Calls; + Assert.NotNull(compiled); + } + + [Fact] + public async Task NativeDisableSkipsBothGlobalAndEndpointLimits() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, + native: o => o.GlobalLimiter = PartitionedRateLimiter.Create(_ => + RateLimitPartition.GetFixedWindowLimiter("global", _ => new() + { PermitLimit = 1, Window = TimeSpan.FromMinutes(1) })), disableNative: true); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + } + + [Fact] + public async Task GlobalAndBuiltInEndpointLimitsBothApply() + { + await using var host = await Host.Create(o => o.RateLimiting.Login.PermitLimit = 1, native: o => + { + o.RejectionStatusCode = 418; + o.GlobalLimiter = PartitionedRateLimiter.Create(_ => + RateLimitPartition.GetFixedWindowLimiter("global", _ => new() { PermitLimit = 4, Window = TimeSpan.FromMinutes(1) })); + }); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Post("/auth/login")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await host.Post("/auth/validate")).StatusCode); + // Native middleware may acquire global permits again after endpoint rejection. + Assert.Equal(418, (int)(await host.Post("/auth/refresh")).StatusCode); + } + + private sealed class Host(WebApplication app) : IAsyncDisposable + { + internal WebApplication App => app; + private readonly HttpClient _client = app.GetTestClient(); + internal async Task Post(string path, string ip = "192.0.2.1", string? forwarded = null) + { + using var request = new HttpRequestMessage(HttpMethod.Post, path); + request.Headers.Add("Test-Connection-IP", ip); + if (forwarded is not null) request.Headers.Add("X-Forwarded-For", forwarded); + return await _client.SendAsync(request); + } + internal static async Task Create(Action? configure = null, + bool aspNet = true, bool? enabled = null, bool repeat = false, + Action? native = null, bool applicationPolicy = false, bool trustedProxy = false, bool disableNative = false, RateLimiterOptions? middlewareOptions = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production" }); + builder.WebHost.UseTestServer(); + builder.Services.AddUltimateAuthServer(configure).AddUltimateAuthInMemory(); + if (native is not null) builder.Services.AddRateLimiter(native); + var app = builder.Build(); + app.Use((context, next) => + { + // Test transport only; production never reads this header. + context.Connection.RemoteIpAddress = IPAddress.Parse(context.Request.Headers["Test-Connection-IP"].ToString()); + return next(context); + }); + if (trustedProxy) + { + var forwarding = new Microsoft.AspNetCore.Builder.ForwardedHeadersOptions + { ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor }; + forwarding.KnownProxies.Add(IPAddress.Parse("192.0.2.1")); + app.UseForwardedHeaders(forwarding); + } + app.UseRouting(); + if (middlewareOptions is not null) app.UseRateLimiter(middlewareOptions); + if (aspNet) + { + if (middlewareOptions is not null) app.UseUltimateAuthWithAspNetCore(enableCors: null, useRateLimiting: false); + else if (enabled is null) app.UseUltimateAuthWithAspNetCore(); + else app.UseUltimateAuthWithAspNetCore(enableCors: null, useRateLimiting: enabled.Value); + if (repeat) app.UseUltimateAuthWithAspNetCore(); + } + else app.UseUltimateAuth(useRateLimiting: enabled ?? false); + var group = app.MapGroup(""); + if (applicationPolicy) group.RequireRateLimiting("Sensitive"); + if (disableNative) group.DisableRateLimiting(); + group.MapUltimateAuthEndpoints(); + group.MapUAuthHub(); + // Exercise the real endpoint inventory and middleware without invoking business handlers. + ((IEndpointConventionBuilder)group).Finally(b => b.RequestDelegate = context => { context.Response.StatusCode = 204; return Task.CompletedTask; }); + await app.StartAsync(); + return new Host(app); + } + public async ValueTask DisposeAsync() { _client.Dispose(); await app.DisposeAsync(); } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility.csproj b/tests/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility.csproj new file mode 100644 index 00000000..039804bc --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility/CodeBeam.UltimateAuth.Tests.RateLimiting.Compatibility.csproj @@ -0,0 +1,25 @@ + + + net8.0;net9.0;net10.0 + false + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs index b2aeea08..d2257ae3 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs @@ -144,6 +144,40 @@ public async Task SendFormAsync_WhenBootstrapperFails_DoesNotInvokeJavascript() JSInterop.Invocations.Should().BeEmpty(); } + [Fact] + public async Task TryAndCommitThrottleKeepsStatusAndRetryGuidance() + { + JSInterop.Setup("uauth.tryAndCommit", _ => true) + .SetResult(System.Text.Json.JsonSerializer.SerializeToElement(new { status = 429, retryAfter = "45", isSuccess = false })); + var error = await Assert.ThrowsAsync(() => + CreateSut().TryAndCommitAsync("/try", "/commit", new { })); + Assert.Equal(System.Net.HttpStatusCode.TooManyRequests, error.StatusCode); + Assert.Equal("rate_limited", error.Code); + Assert.Equal(TimeSpan.FromSeconds(45), error.RetryAfter); + } + + [Fact] + public async Task TryAndCommitStillDeserializesSuccessfulPreview() + { + JSInterop.Setup("uauth.tryAndCommit", _ => true) + .SetResult(System.Text.Json.JsonSerializer.SerializeToElement(new { isSuccess = true, previewReceipt = "receipt" })); + var result = await CreateSut().TryAndCommitAsync("/try", "/commit", new { }); + Assert.True(result.IsSuccess); + Assert.Equal("receipt", result.PreviewReceipt); + } + + [Fact] + public async Task ObservedLogoutReturnsHttpRejectionRatherThanSubmittingAnUnobservedForm() + { + JSInterop.Setup("uauth.post", _ => true) + .SetResult(new UAuthTransportResult { Status = 429, RetryAfter = "15" }); + var result = await CreateSut().SendFormAsync("/auth/logout", null, navigateOnSuccess: true); + Assert.Equal(429, result.Status); + Assert.Equal("15", result.RetryAfter); + var invocation = JSInterop.Invocations.Single(x => x.Identifier == "uauth.post"); + Assert.Equal("logout", GetProperty(invocation.Arguments.Single()!, "mode")); + } + private sealed class TestTryResult { } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientRateLimitingTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientRateLimitingTests.cs new file mode 100644 index 00000000..e3ee0872 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientRateLimitingTests.cs @@ -0,0 +1,181 @@ +using System.Net; +using System.Text.Json; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class ClientRateLimitingTests : UAuthClientTestBase +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("invalid", null)] + [InlineData("-1", null)] + [InlineData("1.5", null)] + [InlineData("60", 60)] + [InlineData("0", 0)] + [InlineData(" 120 ", 120)] + [InlineData("9999999999999999999999999", null)] + public void RetryAfterHandlesMissingInvalidAndDeltaSeconds(string? header, int? expected) + { + Assert.Equal(expected is null ? null : TimeSpan.FromSeconds(expected.Value), UAuthRateLimit.ParseRetryAfter(header, DateTimeOffset.UtcNow)); + } + + [Fact] + public void RetryAfterParsesHttpDateAndPastDate() + { + var now = new DateTimeOffset(2026, 11, 1, 12, 0, 0, TimeSpan.Zero); + Assert.Equal(TimeSpan.FromMinutes(2), UAuthRateLimit.ParseRetryAfter(now.AddMinutes(2).ToString("r"), now)); + Assert.Equal(TimeSpan.Zero, UAuthRateLimit.ParseRetryAfter(now.AddMinutes(-1).ToString("r"), now)); + } + + [Theory] + [InlineData(null)] + [InlineData("90")] + public async Task ValidationThrottlePreservesStateAndBlazorPrincipal_ThenRevocationStillClears(string? retry) + { + var clock = new TestClock(); + var snapshot = new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { UserKey = UserKey.FromString("rate-user"), Tenant = TenantKey.Single }, + Claims = ClaimsSnapshot.Empty + }; + Request.SetupSequence(x => x.SendFormAsync("/auth/validate", null, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 200, Ok = true, Body = JsonSerializer.SerializeToElement(new AuthValidationResult { State = SessionState.Active, Snapshot = snapshot }) }) + .ReturnsAsync(new UAuthTransportResult { Status = 429, RetryAfter = retry }) + .ReturnsAsync(new UAuthTransportResult { Status = 200, Ok = true, Body = JsonSerializer.SerializeToElement(new AuthValidationResult { State = SessionState.Revoked }) }); + using var manager = new UAuthStateManager(CreateClient(flows: CreateFlowClient()), Events.Object, clock); + var provider = new UAuthAuthenticationStateProvider(manager); + await manager.EnsureAsync(); + var lastValidated = manager.State.LastValidatedAt; + await manager.EnsureAsync(force: true); + await manager.EnsureAsync(force: true); + Assert.True(manager.State.IsAuthenticated); + Assert.True((await provider.GetAuthenticationStateAsync()).User.Identity!.IsAuthenticated); + Assert.Equal(lastValidated, manager.State.LastValidatedAt); + Request.Verify(x => x.SendFormAsync("/auth/validate", null, It.IsAny()), Times.Exactly(2)); + clock.Advance(TimeSpan.FromMinutes(2)); + await manager.EnsureAsync(force: true); + Assert.False(manager.State.IsAuthenticated); + Assert.False((await provider.GetAuthenticationStateAsync()).User.Identity!.IsAuthenticated); + } + + [Theory] + [InlineData(null)] + [InlineData("60")] + public async Task RefreshIsThrottledWithoutReauthOrImmediateRepeatedRequest(string? retry) + { + Request.Setup(x => x.SendFormAsync("/auth/refresh", null, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 429, RetryAfter = retry, RefreshOutcome = "reauth-required" }); + var flow = CreateFlowClient(); + var result = await flow.RefreshAsync(); + Assert.False(result.IsSuccess); + Assert.Equal(429, result.Status); + Assert.Equal("rate_limited", result.ErrorCode); + Assert.Equal(RefreshOutcome.None, result.Outcome); + Assert.Equal(retry is null ? null : TimeSpan.FromSeconds(60), result.RetryAfter); + await flow.RefreshAsync(isAuto: true); + Request.Verify(x => x.SendFormAsync("/auth/refresh", null, It.IsAny()), Times.Once); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GenuineRefresh401StillRequiresReauth() + { + Request.Setup(x => x.SendFormAsync("/auth/refresh", null, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 401 }); + var result = await CreateFlowClient().RefreshAsync(); + Assert.Equal(401, result.Status); + Assert.Equal(RefreshOutcome.ReauthRequired, result.Outcome); + } + + [Theory] + [InlineData(UAuthSubmitMode.TryOnly)] + [InlineData(UAuthSubmitMode.TryAndCommit)] + public async Task LoginThrottleHasDistinctResultAndNeverCommits(UAuthSubmitMode mode) + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 429, RetryAfter = "60" }); + Request.Setup(x => x.TryAndCommitAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(UAuthRateLimit.Exception("60")); + IUAuthTryResult result = await CreateFlowClient().TryLoginAsync(new LoginRequest(), mode); + Assert.False(result.IsSuccess); + Assert.Null(result.Reason); + Assert.Equal(429, result.Status); + Assert.Equal("rate_limited", result.ErrorCode); + Assert.Equal(TimeSpan.FromMinutes(1), result.RetryAfter); + Request.Verify(x => x.NavigateAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task LogoutThrottleIsNotSuccessAndDoesNotPublishLogout() + { + Request.Setup(x => x.SendFormAsync("/auth/logout", null, true, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 429 }); + var error = await Assert.ThrowsAsync(() => CreateFlowClient().LogoutAsync()); + Assert.Equal(HttpStatusCode.TooManyRequests, error.StatusCode); + Assert.Equal("rate_limited", error.Code); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task AuthorizationAndRecoveryThrottleAreResults_NotForbiddenOrRetry() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 429, RetryAfter = "30" }); + var result = await CreateClient().Authorization.CheckAsync(new CodeBeam.UltimateAuth.Authorization.Contracts.AuthorizationCheckRequest { Action = "read", Resource = "example" }); + Assert.Equal(429, result.Status); + Assert.True(result.Http.IsTooManyRequests); + Assert.False(result.Http.IsForbidden); + Assert.Equal("rate_limited", result.Problem!.Detail); + Assert.Equal(TimeSpan.FromSeconds(30), result.Problem.RetryAfter); + Assert.Null(result.Value); + var recovery = await CreateClient().Credentials.CompleteResetAsync(new CodeBeam.UltimateAuth.Credentials.Contracts.CompleteResetCredentialRequest + { Identifier = "user", CredentialType = CredentialType.Password, ResetToken = "proof", NewSecret = "changed-password" }); + Assert.Equal(429, recovery.Status); + Assert.Equal("rate_limited", recovery.Problem!.Detail); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + Request.Verify(x => x.SendJsonAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Theory] + [InlineData(null)] + [InlineData(120)] + public async Task AutomaticRefreshDefersThrottle_ThenHonorsReal401(int? seconds) + { + var clock = new TestClock(); + var flows = new Mock(); + flows.SetupSequence(x => x.RefreshAsync(true)) + .ReturnsAsync(new RefreshResult { Status = 429, ErrorCode = "rate_limited", RetryAfter = seconds is null ? null : TimeSpan.FromSeconds(seconds.Value) }) + .ReturnsAsync(new RefreshResult { Status = 401, Outcome = RefreshOutcome.ReauthRequired }); + var diagnostics = new UAuthClientDiagnostics(); + await using var coordinator = new SessionCoordinator(CreateClient(flows: flows.Object), Mock.Of(), + Options.Create(new UAuthClientOptions { Reauth = new() { Behavior = ReauthBehavior.RaiseEvent } }), diagnostics, clock); + await coordinator.TickAsync(); + await coordinator.TickAsync(); + Assert.False(diagnostics.IsTerminated); + flows.Verify(x => x.RefreshAsync(true), Times.Once); + clock.Advance(TimeSpan.FromMinutes(3)); + await coordinator.TickAsync(); + Assert.True(diagnostics.IsTerminated); + flows.Verify(x => x.RefreshAsync(true), Times.Exactly(2)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs index ee8b992c..03992e17 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -320,19 +320,14 @@ await client.LoginAsync(new LoginRequest } [Fact] - public async Task Logout_Should_Navigate_To_Logout() + public async Task Logout_Should_Observe_Server_Response() { var mock = new Mock(); - - mock.Setup(x => x.NavigateAsync( - It.IsAny(), - null, - It.IsAny())) - .Returns(Task.CompletedTask); - + mock.Setup(x => x.SendFormAsync("/auth/logout", null, true, It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Status = 200, Ok = true }); var client = CreateFlowClient(mock); await client.LogoutAsync(); - mock.Verify(x => x.NavigateAsync("/auth/logout", null, It.IsAny()), Times.Once); + mock.Verify(x => x.SendFormAsync("/auth/logout", null, true, It.IsAny()), Times.Once); } [Fact] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs new file mode 100644 index 00000000..87f1b924 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RateLimitingPartitionTests.cs @@ -0,0 +1,102 @@ +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Server.RateLimiting; +using Microsoft.AspNetCore.Http; +using System.Net; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public class RateLimitingPartitionTests +{ + [Fact] + public void DuplicateServerRegistrationRetainsExistingGuard() + { + var services = new ServiceCollection(); + services.AddUltimateAuthServer(); + var error = Assert.Throws(() => services.AddUltimateAuthServer()); + Assert.Contains("policies already registered", error.Message); + } + + [Fact] + public async Task RejectionForwardsNativeRetryMetadata() + { + using var limiter = new System.Threading.RateLimiting.FixedWindowRateLimiter(new() + { PermitLimit = 1, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }); + using var acquired = limiter.AttemptAcquire(); + using var rejected = limiter.AttemptAcquire(); + var context = new DefaultHttpContext(); + context.RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(); + context.Response.Body = new MemoryStream(); + var policy = new UAuthRateLimitPolicy(UAuthEndpointClassification.Get(UAuthEndpoint.Login), new(), "login"); + await policy.OnRejected!(new Microsoft.AspNetCore.RateLimiting.OnRejectedContext + { HttpContext = context, Lease = rejected }, default); + Assert.Equal(429, context.Response.StatusCode); + Assert.Equal("no-store", context.Response.Headers.CacheControl.ToString()); + Assert.True(int.Parse(context.Response.Headers.RetryAfter.ToString()) > 0); + } + + [Fact] + public void EveryEndpointHasTheReviewedClassification() + { + var all = Enum.GetValues().Select(UAuthEndpointClassification.Get).ToArray(); + Assert.Equal(79, all.Length); + Assert.Equal(20, all.Count(x => x.Group == UAuthRateLimitGroup.Sensitive)); + Assert.Equal(55, all.Count(x => x.Group == UAuthRateLimitGroup.Standard)); + Assert.Equal(4, all.Count(x => x.Group == UAuthRateLimitGroup.Frequent)); + Assert.Equal(UAuthRateLimitGroup.Sensitive, UAuthEndpointClassification.Get(UAuthEndpoint.CredentialResetBeginAnonymous).Group); + Assert.Equal(UAuthRateLimitGroup.Standard, UAuthEndpointClassification.Get(UAuthEndpoint.VerifyUserIdentifierAdmin).Group); + Assert.Equal(UAuthRateLimitGroup.Frequent, UAuthEndpointClassification.Get(UAuthEndpoint.Logout).Group); + } + + [Fact] + public void ValidatedActorsAreTenantIsolated_AndTargetsCannotChangeBudget() + { + var policy = new UAuthRateLimitPolicy(UAuthEndpointClassification.Get(UAuthEndpoint.Refresh), new(), "refresh"); + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Loopback; + var anonymous = policy.GetPartition(context).PartitionKey; + UAuthRateLimitPolicy.SetValidatedActor(context, "tenant-a", "actor"); + var actor = policy.GetPartition(context).PartitionKey; + Assert.NotEqual(anonymous, actor); + context.Request.RouteValues["userKey"] = "target-2"; + context.Request.Headers["X-UAuth-ClientProfile"] = "Maui"; + Assert.Equal(actor, policy.GetPartition(context).PartitionKey); + UAuthRateLimitPolicy.SetValidatedActor(context, "tenant-b", "actor"); + Assert.NotEqual(actor, policy.GetPartition(context).PartitionKey); + } + + [Fact] + public void LoginAlwaysUsesIp_AndForwardingHeadersAreNotTrusted() + { + var policy = new UAuthRateLimitPolicy(UAuthEndpointClassification.Get(UAuthEndpoint.Login), new(), "login"); + var context = new DefaultHttpContext(); + context.Connection.RemoteIpAddress = IPAddress.Parse("::ffff:192.0.2.1"); + var key = policy.GetPartition(context).PartitionKey; + context.Request.Headers["X-Forwarded-For"] = "203.0.113.8"; + context.Request.Headers["CF-Connecting-IP"] = "203.0.113.9"; + UAuthRateLimitPolicy.SetValidatedActor(context, "tenant-a", "actor"); + Assert.Equal(key, policy.GetPartition(context).PartitionKey); + Assert.Equal("192.0.2.1", CodeBeam.UltimateAuth.Server.Infrastructure.ClientIpAddress.Resolve(context)); + } + + [Fact] + public void OptionsCloneDoesNotShareMutableRules() + { + var original = new UAuthRateLimitingOptions(); + Assert.Equal(30, original.Sensitive.PermitLimit); + Assert.Equal(120, original.Standard.PermitLimit); + Assert.Equal(600, original.Frequent.PermitLimit); + var reset = original.Resolve(UAuthEndpointClassification.Get(UAuthEndpoint.CredentialResetBeginAnonymous)); + Assert.Equal(5, reset.Rule.PermitLimit); + Assert.Equal(TimeSpan.FromMinutes(10), reset.Rule.Window); + original.Standard.PermitLimit = 240; + Assert.Equal(240, original.Resolve(UAuthEndpointClassification.Get(UAuthEndpoint.GetMyChains)).Rule.PermitLimit); + original.Endpoints[UAuthEndpoint.Login] = new() { PermitLimit = 8 }; + var clone = original.Clone(); + clone.Login.PermitLimit = 1; + clone.Endpoints[UAuthEndpoint.Login].PermitLimit = 2; + Assert.Equal(20, original.Login.PermitLimit); + Assert.Equal(8, original.Endpoints[UAuthEndpoint.Login].PermitLimit); + Assert.NotNull(UAuthRateLimitValidation.Validate(new() { Frequent = new() { PermitLimit = 0 } })); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs b/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs index c62d68dc..e3b56edb 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs @@ -18,10 +18,17 @@ function createEnvironment(fetchImpl = async () => ({ get: () => "application/json" }, text: async () => '{"id":1}' -})) { +}), onNavigate = () => {}) { + const memoryStorage = () => { + const values = new Map(); + return { setItem: (key, value) => values.set(key, value), getItem: key => values.get(key) ?? null, removeItem: key => values.delete(key) }; + }; const window = { + localStorage: memoryStorage(), + sessionStorage: memoryStorage(), location: { - origin: "https://localhost:6130" + origin: "https://localhost:6130", + assign: url => onNavigate(url) } }; @@ -320,3 +327,145 @@ test("cancelResourceRequest aborts active request", async () => { false ); }); + +test("429 try-and-commit returns throttle metadata without committing or reading an auth-result body", async () => { + let calls = 0; + const uauth = createEnvironment(async () => { + calls++; + return { ok: false, status: 429, headers: { get: name => name === "Retry-After" ? "90" : null }, text: async () => "not-json" }; + }); + const result = await uauth.tryAndCommit({ tryUrl: "/auth/try-login", commitUrl: "/auth/login", data: {}, clientProfile: "BlazorServer" }); + assert.equal(result.status, 429); + assert.equal(result.errorCode, "rate_limited"); + assert.equal(result.retryAfter, "90"); + assert.equal(result.isSuccess, false); + assert.equal(calls, 1); +}); + +test("logout 429 is observable and never navigates or changes stored tokens", async () => { + let calls = 0; + + const uauth = createEnvironment(async (_url, request) => { + calls++; + + assert.equal(request.redirect, "error"); + assert.equal( + request.headers["X-UAuth-Logout-Mode"], + "observe" + ); + + return { + ok: false, + status: 429, + redirected: false, + headers: { + get: name => name === "Retry-After" ? "15" : null + }, + text: async () => "" + }; + }, () => assert.fail("Rejected logout must not navigate")); + + uauth.storage.set("local", "access", "still-valid-access"); + uauth.storage.set("session", "refresh", "still-valid-refresh"); + + const result = await uauth.post({ + url: "/auth/logout", + mode: "logout", + clientProfile: "BlazorServer" + }); + + assert.equal(result.status, 429); + assert.equal(result.retryAfter, "15"); + assert.equal(uauth.storage.get("local", "access"), "still-valid-access"); + assert.equal(uauth.storage.get("session", "refresh"), "still-valid-refresh"); + assert.equal(calls, 1); +}); + +test("resource 429 preserves Retry-After without automatic retry", async () => { + let calls = 0; + const uauth = createEnvironment(async () => { + calls++; + return { ok: false, status: 429, headers: { get: name => name === "Retry-After" ? "Sun, 01 Nov 2026 12:01:00 GMT" : null }, text: async () => "" }; + }); + const result = await uauth.resourceRequest({ url: "/api/data", method: "GET" }); + assert.equal(result.status, 429); + assert.equal(result.retryAfter, "Sun, 01 Nov 2026 12:01:00 GMT"); + assert.equal(calls, 1); +}); + + test("refresh 429 leaves stored credentials untouched and sends only once", async () => { + let calls = 0; + const uauth = createEnvironment(async () => { + calls++; + return { ok: false, status: 429, headers: { get: () => null }, text: async () => "" }; + }); + uauth.storage.set("local", "access", "valid-access"); + uauth.storage.set("session", "refresh", "valid-refresh"); + const result = await uauth.post({ url: "/auth/refresh", mode: "fetch", clientProfile: "BlazorWasm" }); + assert.equal(result.status, 429); + assert.equal(uauth.storage.get("local", "access"), "valid-access"); + assert.equal(uauth.storage.get("session", "refresh"), "valid-refresh"); + assert.equal(calls, 1); + }); + +test("successful observed logout navigates after confirmed success without replaying POST", async () => { + let calls = 0; + let navigation = null; + + const uauth = createEnvironment(async (_url, request) => { + calls++; + + assert.equal(request.redirect, "error"); + assert.equal( + request.headers["X-UAuth-Logout-Mode"], + "observe" + ); + + return { + ok: true, + status: 200, + redirected: false, + headers: { get: () => null }, + text: async () => JSON.stringify({ + isSuccess: true, + redirectUrl: "https://localhost:6130/login" + }) + }; + }, url => navigation = url); + + const result = await uauth.post({ + url: "/auth/logout", + mode: "logout", + clientProfile: "BlazorWasm" + }); + + assert.equal(result.status, 200); + assert.equal(navigation, "https://localhost:6130/login"); + assert.equal(calls, 1); +}); + +test("observed logout rejects unsafe redirect protocols", async () => { + let navigation = null; + + const uauth = createEnvironment(async () => ({ + ok: true, + status: 200, + redirected: false, + headers: { get: () => null }, + text: async () => JSON.stringify({ + isSuccess: true, + redirectUrl: "javascript:alert(1)" + }) + }), url => navigation = url); + + await assert.rejects( + uauth.post({ + url: "/auth/logout", + mode: "logout", + clientProfile: "BlazorWasm" + }), + /Invalid logout redirect protocol/ + ); + + assert.equal(navigation, null); +});