diff --git a/.github/codecov.yml b/.github/codecov.yml
index d27f31d0..1d257667 100644
--- a/.github/codecov.yml
+++ b/.github/codecov.yml
@@ -11,3 +11,7 @@ coverage:
default:
target: 80%
threshold: 5%
+
+ignore:
+ - "samples/**"
+ - "tests/**"
diff --git a/.ultimateauth/ai-guardrails.md b/.ultimateauth/ai-guardrails.md
index 3337aade..10e5785a 100644
--- a/.ultimateauth/ai-guardrails.md
+++ b/.ultimateauth/ai-guardrails.md
@@ -13,17 +13,31 @@ These rules are NON-NEGOTIABLE.
---
-## 2. Forbidden Refactors
+## 2. Protected Architectural Boundaries
+
+AI tools MUST NOT independently introduce changes that alter established architectural or security boundaries.
+
+The following components and mechanisms are considered security-critical:
+
+- AuthFlowContext and its creation lifecycle.
+- AccessContext and its derivation rules.
+- Authentication and authorization separation.
+- Security-sensitive orchestration and authority evaluation.
+- Session and token validation mechanisms.
+- Cross-domain identity and security boundaries.
AI tools MUST NOT:
-- Refactor or replace AuthFlowContext.
-- Refactor or replace AccessContext.
-- Change how AuthFlowContext or AccessContext is created.
-- Introduce lazy, implicit, or on-demand context creation.
-- Merge authentication and authorization logic.
-- Move security logic into stores or domain models.
+- Replace or redesign these mechanisms without explicit maintainer approval.
+- Introduce lazy, implicit, or uncontrolled security context creation.
+- Merge authentication and authorization responsibilities.
+- Move authorization decisions into stores or domain models.
- Introduce client-side authority over identity or access.
+- Remove or bypass established security validation mechanisms.
+
+Internal refactoring MAY be performed when explicitly authorized, provided all applicable architectural invariants remain preserved.
+
+A change that modifies a security boundary MUST be treated as an architectural change, not as a routine refactor.
---
@@ -31,12 +45,17 @@ AI tools MUST NOT:
AI tools MUST NOT:
-- Mutate AuthFlowContext after creation.
-- Create more than one AuthFlowContext per request.
-- Create AccessContext independently of AuthFlowContext.
-- Bypass context usage in application or domain code.
+- Arbitrarily mutate an established AuthFlowContext.
+- Replace or reconstruct security contexts outside designated framework mechanisms.
+- Create AccessContext independently of trusted authentication state.
+- Allow application or domain code to override established authentication or authorization context.
+- Introduce hidden, implicit, or uncontrolled security context creation.
+
+Within an HTTP authentication pipeline, the effective authentication context MUST remain stable throughout its execution scope.
-Context objects define security boundaries.
+Non-HTTP execution environments MAY use explicitly defined context creation mechanisms, provided equivalent security guarantees are preserved.
+
+Any proposed change to context ownership, creation, or derivation MUST receive explicit maintainer approval.
---
@@ -44,31 +63,47 @@ Context objects define security boundaries.
AI tools MUST NOT:
-- Allow security-relevant operations to bypass orchestrators.
-- Call stores directly for security-sensitive operations.
-- Embed policy or authorization logic in services or stores.
-- Bypass authority evaluation for any operation that affects:
- - authentication state
- - authorization decisions
- - session validity
- - credentials
- - user security state
+- Bypass required orchestrators in security-sensitive workflows.
+- Bypass applicable authority decisions.
+- Embed authorization policies or permission decisions in stores.
+- Introduce direct persistence operations that circumvent required security checks.
+- Move cross-domain security coordination into unrelated components.
+
+AI tools MUST preserve the designated execution path for each security-sensitive operation.
-All such operations MUST pass through an orchestrator and an authority component.
+- Operations requiring authorization decisions MUST use the applicable authority.
+- Complex security-sensitive workflows MUST use their designated orchestrators.
+- Cryptographic verification, protocol validation, and deterministic security checks MAY be performed by dedicated validators or security primitives.
+- Stores MUST remain persistence-focused and MUST NOT independently make authorization decisions.
+
+AI tools MUST NOT introduce unnecessary authority or orchestration dependencies for operations that do not require them.
---
## 5. Session and Token Rules
+AI tools MUST preserve the authentication, validation, and revocation semantics defined for each authentication mode.
+
AI tools MUST NOT:
-- Treat tokens as the primary source of identity.
-- Introduce token-only authentication flows.
-- Bypass server-side session validation.
-- Weaken revocation or invalidation guarantees.
-- Introduce eventual or best-effort revocation semantics.
+- Treat unvalidated client-provided credentials as trusted identity.
+- Introduce token validation paths that bypass the requirements of the effective authentication mode.
+- Remove required session validation from stateful authentication modes.
+- Introduce mandatory request-time session lookups into stateless modes without explicit architectural approval.
+- Weaken session or token revocation guarantees without explicit maintainer approval.
+- Claim immediate revocation when the implementation cannot enforce it.
+- Change token issuance or validation semantics as an incidental refactor.
+
+The following mode-specific boundaries MUST be preserved:
+
+- **PureOpaque:** Server-side session authentication.
+- **Hybrid:** Stateful authentication with opaque access tokens, refresh tokens, and server-side session validation.
+- **SemiHybrid:** Stateless JWT access-token validation with server-side session lifecycle management.
+- **PureJwt:** Stateless JWT authentication without mandatory session persistence.
-Sessions are server-authoritative.
+AI tools MUST NOT change the authentication mode contract without explicit maintainer approval.
+
+Revocation behavior MUST remain consistent with the documented guarantees of the effective authentication mode.
---
@@ -77,11 +112,20 @@ Sessions are server-authoritative.
AI tools MUST NOT:
- Merge UserLifecycle, UserProfile, or UserIdentifier domains.
-- Introduce cross-domain state sharing.
-- Allow domains to modify each other directly.
-- Treat UserKey as a domain entity.
+- Introduce unauthorized cross-domain mutable state sharing.
+- Allow domains to modify each other's state directly.
+- Treat UserKey as a domain entity or persistence entity.
+- Introduce mandatory UltimateAuth inheritance requirements for host user entities.
+- Require host application user models to implement UltimateAuth-specific interfaces.
+- Couple framework-owned runtime identity records directly to host persistence entities.
+
+UserKey MUST remain an opaque cross-domain identity anchor.
+
+Host user models MUST remain independent of UltimateAuth framework types.
-UserKey is an opaque cross-domain identity anchor.
+Integration with host user entities MUST occur through explicit adapters, providers, or controlled boundary contracts.
+
+Cross-domain security operations MUST be coordinated through designated services and orchestrators.
---
@@ -89,10 +133,16 @@ UserKey is an opaque cross-domain identity anchor.
AI tools MUST NOT:
-- Introduce runtime-specific authentication semantics.
-- Allow clients or SDKs to create identity.
-- Move authorization decisions to the client.
-- Remove or weaken PKCE requirements for public clients.
+- Introduce runtime-specific security authority.
+- Allow client SDKs to establish trusted identity without server-side validation.
+- Move authorization decisions to untrusted clients.
+- Weaken PKCE requirements for authorization-code flows involving public clients.
+- Introduce inconsistent security guarantees for the same authentication mode across different runtimes.
+- Duplicate core authentication logic solely to accommodate a client platform.
+
+Client runtimes MAY use different transport mechanisms, credential delivery strategies, and authentication flow adaptations.
+
+Runtime-specific adaptations MUST preserve the security guarantees of the effective authentication mode.
Client SDKs are adapters, not security authorities.
@@ -102,17 +152,122 @@ Client SDKs are adapters, not security authorities.
AI tools MUST NOT:
-- Introduce extension points that bypass security invariants.
-- Allow overrides to weaken orchestration or authority rules.
-- Alter server-authoritative behavior for convenience.
+- Introduce extension points that bypass mandatory security controls.
+- Allow overrides to circumvent applicable authority decisions.
+- Allow plugins to weaken authentication mode guarantees.
+- Introduce hidden security behavior through extension mechanisms.
+- Alter server-authoritative security boundaries for convenience or performance.
+- Couple host application domain models directly to framework-owned persistence or runtime models.
+
+Extensions MUST preserve the security invariants applicable to the effective authentication mode.
-Extensibility MUST preserve all security guarantees.
+New extension points affecting authentication, authorization, credential handling, or session lifecycle MUST receive explicit maintainer approval before implementation.
---
-## 9. Final Enforcement Rule
+## 9. Change Authorization and Review
+
+AI tools MUST classify proposed changes before implementation.
+
+### 9.1 Routine Changes
+
+Routine changes MAY proceed within the explicitly authorized task scope when they preserve all applicable architectural and security invariants.
+
+Examples include:
+
+- Documentation corrections.
+- Non-behavioral code cleanup.
+- Focused bug fixes that preserve established contracts.
+- Additional tests for existing behavior.
+
+### 9.2 Security-Critical Changes
+
+The following changes REQUIRE explicit maintainer approval:
+
+- Authentication mode behavior changes.
+- Token issuance, validation, or revocation changes.
+- Security context creation or lifecycle changes.
+- Authorization or authority evaluation changes.
+- Session lifecycle or persistence semantics changes.
+- Credential handling or verification changes.
+- New security-sensitive extension points.
+- Changes to public security contracts.
+
+Approval for one change MUST NOT be interpreted as approval for unrelated changes.
+
+### 9.3 Architectural Conflicts
+
+If a requested change conflicts with `.ultimateauth/architecture.md`, AI tools MUST:
+
+1. Identify the conflicting architectural rule.
+2. Explain why the proposed change conflicts with that rule.
+3. Present a compliant alternative when possible.
+4. Request explicit maintainer review if an architectural amendment is necessary.
+5. Refrain from implementing the conflicting change until the canonical architecture has been explicitly updated.
+
+AI tools MUST NOT silently modify architectural rules to justify an implementation.
+
+---
+
+## 10. Verification Requirements
+
+AI tools MUST verify that their changes preserve applicable architectural and security guarantees.
+
+For security-relevant changes, AI tools MUST:
+
+- Identify affected authentication modes and client profiles.
+- Review relevant issuance, validation, authorization, and revocation paths.
+- Preserve existing negative security tests.
+- Add or update focused regression tests when behavior changes.
+- Check for unintended changes to public contracts.
+- Report any validation or testing that could not be completed.
+
+AI tools MUST NOT:
+
+- Remove or weaken security tests merely to make a change pass.
+- Modify unrelated security behavior without explicit authorization.
+- Claim that a change is secure solely because the project builds successfully.
+- Claim tests passed when they were not executed.
+- Conceal unresolved security concerns or architectural conflicts.
+
+If the required verification cannot be completed, the AI tool MUST clearly report the limitation.
+
+---
+
+## 11. Final Enforcement Rule
+
+If an AI tool cannot determine whether a proposed change preserves the applicable architectural and security invariants, it MUST NOT proceed with the uncertain change without explicit maintainer review.
+
+AI tools MUST distinguish between:
+
+- A confirmed architectural violation.
+- A potential security or architectural concern.
+- An implementation detail that does not alter architectural guarantees.
+
+AI tools MUST NOT treat uncertainty as evidence of a confirmed vulnerability.
+
+Explicit maintainer approval is required for architectural changes, but approval alone does not override the canonical architecture.
+
+When an architectural change is necessary, `.ultimateauth/architecture.md` MUST be intentionally reviewed and updated before implementing the conflicting behavior.
+
+Security correctness, architectural consistency, and verifiable behavior take precedence over convenience, performance, or refactoring elegance.
+
+---
+
+## 12. Scope Discipline
+
+AI tools MUST remain within the scope of the explicitly authorized task.
+
+AI tools MUST NOT:
+
+- Expand a focused bug fix into an unrelated architectural refactor.
+- Implement roadmap features without explicit authorization.
+- Change authentication mode semantics while fixing unrelated issues.
+- Introduce new abstractions solely for speculative future requirements.
+- Remove public contracts without reviewing their intended purpose and compatibility impact.
+- Treat TODO comments as authorization to implement unfinished features.
+- Modify unrelated files merely for stylistic consistency.
-If an AI tool is uncertain whether a change violates these guardrails, the change MUST NOT be made.
+When an unrelated issue is discovered, AI tools SHOULD report it separately rather than modifying it without authorization.
-Security correctness takes precedence over convenience,
-performance, or refactoring elegance.
+Deferred work MUST remain deferred unless explicitly reopened by the maintainer.
\ No newline at end of file
diff --git a/.ultimateauth/architecture.md b/.ultimateauth/architecture.md
index 393e820d..4a8ecc02 100644
--- a/.ultimateauth/architecture.md
+++ b/.ultimateauth/architecture.md
@@ -76,44 +76,55 @@ This document is NOT intended as an onboarding guide or end-user documentation.
## 2. Core Authentication Model
-UltimateAuth defines authentication as a session-centered, server-authoritative process.
-Authentication determines *who* the current actor is and establishes a stable identity context for the duration of a request or session. Authorization decisions are explicitly out of scope for this model and are handled separately.
+UltimateAuth defines authentication as a server-authoritative process that establishes and validates the identity of the current actor.
+
+Authentication determines *who* the current actor is and establishes a trustworthy identity context for the duration of an authenticated operation.
+
+Authentication state MAY be represented through server-side sessions, cryptographically verifiable tokens, or a combination of both, depending on the effective authentication mode.
+
+Authorization decisions are explicitly separate from authentication and are handled through dedicated authorization mechanisms.
---
-### 2.1 Session as the Source of Truth
+### 2.1 Server Authority and Authentication State
-In UltimateAuth, sessions are the primary and authoritative representation of authentication state.
+UltimateAuth treats the server as the authoritative source of authentication rules and security decisions.
-- A session represents an authenticated identity.
-- Tokens, cookies, and other credentials are transport mechanisms, not identity sources.
-- Authentication state MUST be verifiable on the server using session-backed data.
+- Authentication authority MUST remain under server control.
+- Client-provided identity information MUST NOT be trusted without validation.
+- Authentication credentials MUST be validated according to the effective authentication mode.
+- Session-backed modes MUST preserve the authority of server-side session state.
+- Stateless modes MAY establish authenticated identity through cryptographically verifiable tokens without request-time session lookup.
-Any authentication model that treats tokens as the primary source of identity is explicitly rejected.
+Tokens, cookies, and other credentials represent evidence of authentication. Their presence alone MUST NOT establish authenticated identity without the validation required by the effective authentication mode.
+
+No authentication mode may delegate security authority to an untrusted client.
---
### 2.2 Authentication Modes
-UltimateAuth supports multiple authentication modes to address different deployment and runtime requirements.
+UltimateAuth defines multiple authentication modes to support different security, lifecycle, and runtime requirements.
The following authentication modes are defined:
- **PureOpaque**
- Authentication is fully session-based. No client-readable identity tokens (except session id) are exposed.
+ Fully stateful, session-based authentication. The server-side session is the primary authentication credential and source of authentication state. No separate access or refresh token is required.
- **Hybrid**
- Session-backed authentication with client-readable tokens used as a performance optimization.
+ Stateful authentication combining server-side sessions, opaque access tokens, and refresh tokens. Authentication remains session-authoritative, and authenticated requests MUST enforce session validity.
- **SemiHybrid**
- Session-backed authentication with limited client-side identity representation.
+ Authentication combining stateless JWT access-token validation with server-side session lifecycle management. Sessions support operations such as refresh, logout, revocation, and device management. Normal JWT validation does not require a request-time session lookup.
- **PureJwt**
- Token-centric authentication with server-side validation, used only when session-backed models are not feasible.
+ Stateless JWT-based authentication without mandatory server-side session persistence or request-time session lookup. Token validity is established through cryptographic verification and applicable token validation rules.
+
+Authentication modes define different validation and lifecycle strategies, not merely different credential transport formats.
-Authentication modes define *how authentication state is represented and transported*, not *how identity is verified*.
+Each mode MUST preserve the framework's common authentication and authorization boundaries while enforcing its own explicitly defined security guarantees.
-The selected authentication mode MUST NOT alter core authentication semantics.
+Defining an authentication mode does not imply that its implementation is complete or available in every release. Supported modes MUST be documented separately.
---
@@ -158,15 +169,19 @@ Authorization systems MUST rely on authenticated identity provided by the authen
### 2.6 Extensibility Without Semantic Drift
-The core authentication model is extensible through plugin domains and override points.
+The core authentication model is extensible through plugin domains and explicit extension points.
-Extensibility MUST preserve the semantic guarantees defined in this section.
+Extensibility MUST preserve the security guarantees applicable to the effective authentication mode.
-Custom implementations, alternative storage mechanisms, or runtime-specific optimizations MUST NOT change:
+Custom implementations, alternative storage mechanisms, and runtime-specific optimizations MUST NOT change:
-- The session-first nature of authentication.
-- The server-authoritative identity model.
+- The server-authoritative nature of authentication.
- The separation between authentication and authorization.
+- The integrity of authenticated identity and security contexts.
+- The validation and revocation guarantees defined for the selected authentication mode.
+- The requirement that security-sensitive decisions remain under trusted server-side control.
+
+Extensions MUST NOT silently weaken or redefine the security semantics of an authentication mode.
## 3. Request Pipeline & Context Model
@@ -177,16 +192,16 @@ This pipeline is responsible for producing immutable context objects that repres
### 3.1 AuthFlowContext
-AuthFlowContext represents the complete authentication flow state of a request.
+AuthFlowContext represents the effective authentication flow context established for an authentication operation.
-- AuthFlowContext is created exactly once per request.
-- AuthFlowContext is request-scoped.
-- AuthFlowContext is immutable after creation.
-- AuthFlowContext is the single source of truth for authentication-related data during request processing.
+- Within an HTTP authentication pipeline, AuthFlowContext MUST be established through the designated context creation mechanism.
+- Once established, the effective AuthFlowContext MUST remain stable throughout the operation.
+- AuthFlowContext MUST NOT be arbitrarily mutated, replaced, or reconstructed by application or domain code.
+- Non-HTTP execution environments MAY use explicitly defined context creation mechanisms, provided equivalent security guarantees are preserved.
-AuthFlowContext encapsulates all information required to evaluate authentication state, including session data, client characteristics, and runtime-specific signals.
+AuthFlowContext encapsulates the authentication-related information required by the operation, including effective authentication mode, client characteristics, tenant context, and applicable runtime signals.
-AuthFlowContext MUST NOT be modified, replaced, or reconstructed after initial creation.
+Context creation and ownership MUST remain under framework-controlled boundaries.
---
@@ -205,13 +220,14 @@ Authentication decisions MUST be resolved before AccessContext is created.
### 3.3 Context Creation Boundaries
-Context creation is a controlled operation within the request pipeline.
+Context creation is a controlled operation within the authentication and authorization pipeline.
-- AuthFlowContext creation is part of the authentication pipeline and occurs before application logic executes.
-- AccessContext creation is a deterministic transformation of AuthFlowContext.
-- Context objects MUST NOT be created lazily or on demand within application or domain code.
+- Authentication context creation MUST occur through designated framework mechanisms.
+- AccessContext MUST be derived from trusted authentication state and the applicable authorization request context.
+- Context creation MUST NOT be delegated to arbitrary application or domain code.
+- Services and stores MUST NOT independently reconstruct or override established security contexts.
-Application code, services, and stores MUST treat AuthFlowContext and AccessContext as read-only inputs.
+Application code MUST treat established security contexts as trusted, read-only inputs.
---
@@ -221,7 +237,7 @@ Authentication evaluation in UltimateAuth is deterministic and request-based.
- Each request independently derives its authentication and authorization context.
- No implicit cross-request authentication state is allowed.
-- Retried, concurrent, or replayed requests MUST produce equivalent authentication results given the same inputs.
+- Authentication evaluation MUST be consistent for equivalent inputs and equivalent authoritative security state. State transitions, replay protection, token rotation, and revocation MAY legitimately produce different outcomes for otherwise identical requests.
Deterministic evaluation ensures predictable behavior across distributed systems and asynchronous execution.
@@ -241,15 +257,15 @@ The exact integration mechanism (e.g. middleware, endpoint filters, or framework
### 3.6 Architectural Invariants
-The following invariants MUST hold for all implementations:
+The following invariants MUST hold:
-- Exactly one AuthFlowContext exists per request.
-- AuthFlowContext is immutable after creation.
-- AccessContext is derived from AuthFlowContext and never the inverse.
-- Application and domain code MUST NOT influence authentication context creation.
-- Context objects define security boundaries and MUST NOT be bypassed.
+- The effective authentication context remains stable throughout its execution scope.
+- AccessContext is derived from trusted authentication state, never the inverse.
+- Security contexts MUST NOT be arbitrarily modified or replaced by application code.
+- Context creation MUST occur through explicit, controlled boundaries.
+- Context integrity MUST be preserved across supported execution environments.
-Any implementation that violates these invariants is considered architecturally incorrect.
+Any implementation that bypasses these boundaries is architecturally incorrect.
## 4. Domain Boundaries
@@ -319,7 +335,23 @@ The internal representation of UserKey MUST remain flexible and replaceable with
---
-### 4.5 Domain Independence Guarantees
+### 4.5 Host User Model Independence
+
+UltimateAuth MUST remain independent of host application user entity models.
+
+- Host applications MUST NOT be required to inherit from framework-owned user entities.
+- Host user entities MUST NOT be required to implement UltimateAuth-specific interfaces.
+- Integration with host user models MUST occur through explicit adapters, providers, or boundary contracts.
+- Framework-owned runtime records and identity snapshots MUST remain independent of host persistence entities.
+- Mapping between host user identities and UltimateAuth UserKey values MUST occur at controlled integration boundaries.
+
+Reference implementations MAY provide ready-to-use adapters or providers, but MUST NOT impose a mandatory host user model.
+
+UltimateAuth MUST preserve its authentication, authorization, and domain invariants regardless of the host application's user entity structure.
+
+---
+
+### 4.6 Domain Independence Guarantees
The following guarantees MUST hold:
@@ -387,23 +419,30 @@ New orchestrators MAY be introduced as the system evolves.
### 5.4 Authority Components
-Authority components are responsible for making security and authorization decisions.
+Authority components are responsible for security and authorization decisions within their explicitly defined responsibility boundaries.
+
+- Authorities evaluate applicable security policies and permissions.
+- Authorities determine whether protected operations are permitted.
+- Authorities MUST remain independent of persistence implementation details.
+- Authority decisions MUST NOT be bypassed by services or orchestrators.
-- Authorities evaluate policies and permissions.
-- Authorities validate whether an operation is allowed.
-- Authorities are the final decision point for security-sensitive actions.
+Not every security-related validation requires an Authority component.
-Authority logic MUST NOT be embedded in services, stores or domain models.
+Cryptographic verification, token parsing, protocol validation, and other deterministic validation operations MAY be performed by dedicated validators or security primitives.
---
-### 5.5 Mandatory Orchestration Rule
+### 5.5 Mandatory Security Coordination Rule
-All security-relevant operations MUST pass through an orchestrator and an authority.
+Security-sensitive operations MUST follow their designated framework-controlled execution paths.
-No operation that affects authentication state, authorization decisions, session validity, credentials, or user security state may be executed without explicit orchestration and authority evaluation.
+- Operations requiring authorization decisions MUST invoke the applicable authority.
+- Complex security-sensitive workflows MUST use their designated orchestrators.
+- Services MUST NOT bypass required authorization or security checks.
+- Stores MUST NOT independently make authorization decisions.
+- Cryptographic and protocol validation MAY be performed by dedicated components without introducing unnecessary orchestration layers.
-Bypassing orchestrators or authorities is considered a critical architectural violation.
+No implementation may bypass a security boundary required by the operation being performed.
---
@@ -412,30 +451,35 @@ Bypassing orchestrators or authorities is considered a critical architectural vi
The following guarantees MUST hold:
- Stores are never policy-aware.
-- Services never perform security decisions directly.
+- Services MUST NOT independently override required authority decisions.
- Orchestrators always coordinate security-sensitive flows.
- Authorities are the single source of truth for authorization decisions.
-- No execution path may bypass orchestrators and authorities.
+- No execution path may bypass the security controls required by its designated execution model.
Violations of these guarantees compromise system security and are not permitted.
## 6. Session Architecture
-UltimateAuth defines sessions as the primary and authoritative representation of authenticated identity.
+UltimateAuth provides a server-controlled session architecture for authentication modes that require stateful identity management.
+
+Sessions establish authentication continuity, lifecycle management, revocation capabilities, and server-side control over authenticated identity.
-Sessions establish continuity, revocation guarantees, and server-side control over authentication state.
+The role of sessions depends on the effective authentication mode.
+
+Session-backed modes MUST enforce their documented session validation guarantees. Stateless modes MUST NOT be required to introduce server-side session persistence solely to conform to the session architecture.
---
### 6.1 Session as an Authentication Primitive
-A session represents an authenticated identity and its associated security state.
+A session represents server-managed authenticated identity state and its associated security lifecycle.
- Sessions are server-owned and server-validated.
-- Sessions define authentication continuity across requests.
-- Sessions are the authoritative source of authentication truth.
+- Sessions provide authentication continuity and lifecycle control.
+- In stateful authentication modes, sessions are authoritative for authentication validity.
+- In modes using stateless access tokens, sessions MAY remain authoritative for lifecycle operations without participating in every access-token validation.
-Tokens, cookies, or other client-held artifacts are transport mechanisms and MUST NOT be treated as identity sources.
+Client-held credentials MUST be validated according to the effective authentication mode.
---
@@ -454,25 +498,28 @@ Session composition enables controlled delegation, refresh flows, and security i
### 6.3 Session Validation and Resolution
-Session validation is performed on every request that requires authentication.
+Session validation is required whenever the effective authentication mode or operation depends on server-side session state.
-- Session validity MUST be verified server-side.
-- Session resolution MUST be deterministic.
-- Cached or inferred session state is not permitted.
+- Stateful authentication modes MUST validate session validity for authenticated access.
+- Session resolution MUST follow explicit and deterministic rules.
+- Stateless access-token validation MAY operate without request-time session lookup.
+- Operations that require session state, including session refresh and session revocation, MUST validate the applicable session.
-Session resolution MUST NOT depend solely on client-held data.
+Session validation requirements MUST be explicitly defined for each authentication mode.
---
### 6.4 Revocation and Invalidation Semantics
-Session revocation is a first-class security operation.
+Revocation is a first-class security operation in UltimateAuth.
-- Revoked sessions MUST be rejected immediately.
-- Revocation MUST be enforceable across all authentication modes.
-- Session invalidation MUST propagate deterministically.
+- Stateful authentication modes MUST reject revoked sessions during subsequent authentication validation.
+- Session-dependent operations MUST reject revoked or invalid sessions.
+- Stateless access tokens MAY remain valid until expiration unless an additional revocation mechanism is explicitly supported.
+- Revocation guarantees MUST be documented separately for each authentication mode.
+- No implementation may claim immediate access-token revocation without an enforcement mechanism capable of providing that guarantee.
-Eventual or best-effort revocation semantics are explicitly rejected.
+Revocation behavior MUST be explicit, predictable, and consistent with the selected authentication mode.
---
@@ -490,13 +537,14 @@ Refresh mechanisms MUST NOT bypass session validation or authority evaluation.
### 6.6 Relationship Between Sessions and Authentication Modes
-Authentication modes define how session state is represented and transported, not how it is validated.
+Authentication modes determine how authentication credentials are validated and how session state participates in authentication.
-- All authentication modes (except PureJwt) rely on session-backed validation.
-- Token-based representations MUST be verifiable against session state.
-- Switching authentication modes MUST NOT change session semantics.
+- **PureOpaque:** Session state is authoritative for authenticated access.
+- **Hybrid:** Opaque access tokens are validated through server-controlled, session-backed authentication.
+- **SemiHybrid:** JWT access tokens are validated without mandatory request-time session lookup; sessions govern applicable lifecycle operations.
+- **PureJwt:** Authentication is based on stateless JWT validation without mandatory session persistence.
-Session architecture remains consistent across all modes.
+Switching authentication modes MAY change validation and revocation behavior, but MUST NOT weaken the common security boundaries of the framework.
---
@@ -504,13 +552,14 @@ Session architecture remains consistent across all modes.
The following invariants MUST hold:
-- Sessions are the single source of authenticated identity.
-- Session validation occurs server-side.
-- Revocation is immediate and deterministic.
-- Tokens never replace session authority.
-- No authentication flow may bypass session validation.
+- Authentication authority remains under server control.
+- Stateful authentication modes MUST enforce session validity.
+- Stateless authentication modes MUST validate token authenticity, integrity, expiration, and applicable security constraints.
+- Session-dependent operations MUST NOT bypass required session validation.
+- Revocation guarantees MUST match the actual enforcement capabilities of the selected authentication mode.
+- Client-held credentials MUST NOT be trusted without appropriate validation.
-Any implementation that violates these invariants is considered architecturally incorrect.
+Any implementation violating these invariants is architecturally incorrect.
## 7. Client & Runtime Model
@@ -572,13 +621,17 @@ No client runtime is permitted to cache or infer authentication authority outsid
### 7.5 Public Clients and PKCE Requirements
-Public clients (including browser-based and mobile clients) are treated as untrusted environments.
+Public clients, including browser-based and mobile applications, operate in environments that cannot be assumed to protect long-lived client authentication secrets.
+
+- Public clients MUST NOT be trusted to securely maintain confidential client credentials.
+- Authorization-code flows involving public clients MUST use PKCE.
+- PKCE validation MUST follow the supported protocol requirements and security invariants.
+- Client-provided metadata MUST NOT independently establish trusted client identity.
+- Authentication flows MUST account for potentially compromised or malicious clients.
-- Public clients MUST NOT hold secrets.
-- PKCE is REQUIRED for authorization flows involving public clients.
-- Authentication flows MUST assume client compromise is possible.
+User credentials, short-lived authorization artifacts, and PKCE code verifiers MUST NOT be confused with confidential client authentication secrets.
-Security guarantees MUST be preserved even in the presence of malicious or compromised clients.
+Security guarantees MUST remain enforceable at trusted server-side boundaries.
---
@@ -625,15 +678,16 @@ No client runtime, SDK, or application code may assume authority over identity o
---
-### 8.2 Session Authority Invariant
+### 8.2 Authentication State Validation Invariant
-Sessions are the single authoritative source of authenticated identity.
+Authentication state MUST be validated according to the effective authentication mode.
-- Authentication state MUST be session-backed.
-- Tokens, cookies, or headers are transport artifacts only.
-- Session validation MUST occur on every authenticated request.
+- Stateful authentication modes MUST validate the authoritative session state.
+- Stateless authentication modes MUST validate cryptographic token integrity and applicable token constraints.
+- Client-held credentials MUST NOT independently establish authentication authority.
+- Authentication validation MUST NOT bypass security checks required by the effective mode.
-No authentication flow may bypass session validation.
+The framework MUST NOT assume that all authentication modes require request-time session validation.
---
@@ -650,15 +704,17 @@ Context integrity is mandatory for deterministic and secure request processing.
---
-### 8.4 Orchestration Invariant
+### 8.4 Security Coordination Invariant
-All security-relevant operations MUST be orchestrated.
+Security-sensitive operations MUST follow explicitly defined execution and validation boundaries.
-- No security-sensitive action may execute directly against stores or domain models.
-- All such actions MUST pass through an orchestrator and an authority component.
-- Orchestration enforces sequencing, policy evaluation and cross-domain consistency.
+- Operations requiring authorization decisions MUST use the applicable authority components.
+- Complex security-sensitive workflows MUST use their designated orchestrators.
+- Security validation primitives MAY operate independently when orchestration or authority evaluation is not required.
+- Services, stores, and extensions MUST NOT bypass mandatory security controls.
+- Cross-domain security operations MUST preserve coordination and consistency guarantees.
-Bypassing orchestration or authority evaluation is explicitly forbidden.
+No implementation may weaken required security controls by bypassing the designated execution path.
---
@@ -676,37 +732,42 @@ Domain isolation MUST NOT be weakened by persistence or implementation convenien
### 8.6 Credential Protection Invariant
-Credential material is security-critical and requires strict isolation.
+Credential material is security-critical and MUST be handled through controlled security boundaries.
-- Secrets MUST NOT be exposed outside the Credentials domain.
-- Credentials MUST NOT be treated as identifiers or profiles.
-- Credential validation MUST occur server-side.
+- Credential secrets MUST NOT be persisted, logged, or exposed outside authorized credential-handling mechanisms.
+- Credential validation MUST occur through trusted server-side components.
+- Credentials MUST NOT be treated as user identifiers or profile data.
+- Services and transport layers MAY temporarily handle credential material only as required to execute authorized authentication flows.
+- Credential material MUST NOT be propagated to unrelated domains.
-Credential leakage or reuse across domains is forbidden.
+Credential protection MUST remain independent of the host application's persistence model.
---
### 8.7 Deterministic Evaluation Invariant
-Authentication and authorization evaluation MUST be deterministic.
+Authentication and authorization evaluation MUST be deterministic for equivalent inputs and equivalent authoritative security state.
-- Identical inputs MUST produce identical outcomes.
-- Hidden, implicit, or ambient security state is not allowed.
-- Concurrent or retried requests MUST behave consistently.
+- Security decisions MUST follow explicit and predictable rules.
+- Hidden or uncontrolled security authority is not permitted.
+- State transitions, token rotation, replay prevention, expiration, and revocation MAY legitimately change evaluation outcomes.
+- Concurrent operations MUST preserve applicable security invariants.
-Deterministic evaluation is required for correctness, auditability and security.
+Determinism MUST NOT prevent legitimate stateful security transitions.
---
### 8.8 Revocation Invariant
-Revocation is a first-class security operation.
+Revocation guarantees MUST be explicit and consistent with the effective authentication mode.
-- Revoked sessions or credentials MUST be rejected immediately.
-- Revocation MUST be enforceable across all runtimes and authentication modes.
-- Best-effort or eventual revocation is not permitted.
+- Stateful authentication modes MUST enforce session revocation during subsequent authentication validation.
+- Session-dependent operations MUST reject revoked sessions.
+- Stateless tokens MAY remain valid until expiration unless additional revocation enforcement is provided.
+- Revocation behavior MUST NOT be represented as immediate when the implementation cannot enforce immediate invalidation.
+- Runtime-specific implementations MUST preserve the revocation guarantees defined for their authentication mode.
-Revocation guarantees MUST NOT be weakened for performance or convenience.
+Security documentation MUST accurately describe the revocation capabilities and limitations of each supported mode.
---
diff --git a/README.md b/README.md
index ac155657..af4e0371 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,16 @@ We keep it up-to-date with current priorities, planned features, and progress. F
---
+### 💜 Join the UltimateAuth Community
+
+UltimateAuth is more than a framework — it's a growing open-source community.
+
+Join our **[Discord server](https://discord.gg/QscA86dXSR)** to connect with developers, ask questions, share ideas, discuss authentication challenges, and help shape the future of UltimateAuth.
+
+Whether you're using UltimateAuth, exploring the project, or interested in contributing, **you're always welcome!**
+
+---
+
## 🌟 Why UltimateAuth
The Six-Point Principles
@@ -321,12 +331,21 @@ Create accounts, simulate devices, test auth flows, and observe UltimateAuth in
---
-## 🤝 Contributing
+## 🤝 Community & Contributing
+
+UltimateAuth is built by developers, for developers — and everyone is welcome to be part of its journey.
+
+Whether you're reporting a bug, proposing an idea, improving documentation, or submitting code, your contribution matters.
+
+**You don't need to be an expert to get involved.**
-UltimateAuth is a community-first framework.
-We welcome proposals, discussions, architectural insights, and contributions of all sizes.
+- 💜 **[Join our Discord](https://discord.gg/QscA86dXSR)** — Connect with the community and share ideas.
+- 🤝 **[Contributing Guide](CONTRIBUTING.md)** — Learn how to contribute.
+- 🛡️ **[Security Policy](SECURITY.md)** — Report security vulnerabilities responsibly.
+- 📜 **[Code of Conduct](CODE_OF_CONDUCT.md)** — Our community values.
+- 🏛️ **[Governance](GOVERNANCE.md)** — How project decisions are made.
-Discussions are open — your ideas matter.
+Every great open-source project starts with people willing to build something together.
---
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs
index 7897aca7..11625cbc 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs
@@ -96,7 +96,7 @@ private async Task HandleLoginResult(IUAuthTryResult result)
{
if (result is TryPkceLoginResult pkce)
{
- if (!result.Success)
+ if (!result.IsSuccess)
{
if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
{
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.Designer.cs
similarity index 98%
rename from samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs
rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.Designer.cs
index 3f1152e9..4e2a0ca3 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.Designer.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.Designer.cs
@@ -11,8 +11,8 @@
namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations
{
[DbContext(typeof(UAuthDbContext))]
- [Migration("20261004164613_001_Initial")]
- partial class _001_Initial
+ [Migration("20261009201922_001_InitUAuth")]
+ partial class _001_InitUAuth
{
///
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -572,7 +572,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.HasIndex("Tenant", "UserKey", "IsPrimary");
- b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary");
+ b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id");
b.ToTable("UAuth_UserIdentifiers", (string)null);
});
@@ -617,6 +617,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.HasIndex("Tenant", "UserKey")
.IsUnique();
+ b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id");
+
+ b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id");
+
b.ToTable("UAuth_UserLifecycles", (string)null);
});
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.cs
similarity index 97%
rename from samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs
rename to samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.cs
index 77f4d9bf..8b0c9c07 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261004164613_001_Initial.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/20261009201922_001_InitUAuth.cs
@@ -6,7 +6,7 @@
namespace CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.Migrations
{
///
- public partial class _001_Initial : Migration
+ public partial class _001_InitUAuth : Migration
{
///
protected override void Up(MigrationBuilder migrationBuilder)
@@ -492,9 +492,19 @@ protected override void Up(MigrationBuilder migrationBuilder)
columns: new[] { "Tenant", "UserKey", "IsPrimary" });
migrationBuilder.CreateIndex(
- name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary",
+ name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary_DeletedAt_CreatedAt_Id",
table: "UAuth_UserIdentifiers",
- columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary" });
+ columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UAuth_UserLifecycles_Tenant_DeletedAt_CreatedAt_Id",
+ table: "UAuth_UserLifecycles",
+ columns: new[] { "Tenant", "DeletedAt", "CreatedAt", "Id" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UAuth_UserLifecycles_Tenant_Status_DeletedAt_CreatedAt_Id",
+ table: "UAuth_UserLifecycles",
+ columns: new[] { "Tenant", "Status", "DeletedAt", "CreatedAt", "Id" });
migrationBuilder.CreateIndex(
name: "IX_UAuth_UserLifecycles_Tenant_UserKey",
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs
index bef97e5f..de4aec39 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Migrations/UAuthDbContextModelSnapshot.cs
@@ -569,7 +569,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("Tenant", "UserKey", "IsPrimary");
- b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary");
+ b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id");
b.ToTable("UAuth_UserIdentifiers", (string)null);
});
@@ -614,6 +614,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("Tenant", "UserKey")
.IsUnique();
+ b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id");
+
+ b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id");
+
b.ToTable("UAuth_UserLifecycles", (string)null);
});
diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
index a5f93cee..c4b475d3 100644
--- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
+++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs
@@ -96,7 +96,7 @@ private async Task HandleLoginResult(IUAuthTryResult result)
{
if (result is TryPkceLoginResult pkce)
{
- if (!result.Success)
+ if (!result.IsSuccess)
{
if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
{
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs
index f89b038e..0ce2be6f 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs
@@ -92,11 +92,11 @@ private async Task SaveAsync()
if (UserKey is null)
{
- result = await UAuthClient.Users.UpdateMeAsync(request);
+ result = await UAuthClient.Users.UpdateMyProfileAsync(request);
}
else
{
- result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request);
+ result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request);
}
if (result.IsSuccess)
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs
index 011bdec3..2c130d17 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs
@@ -161,7 +161,7 @@ private void HandleTry(IUAuthTryResult result)
{
if (result is TryLoginResult pkce)
{
- if (!result.Success)
+ if (!result.IsSuccess)
{
if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
{
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs
index 2cd5e204..9c453a4d 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs
@@ -25,10 +25,16 @@ private async Task ResetPasswordAsync()
return;
}
+ if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier))
+ {
+ Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error);
+ return;
+ }
+
var request = new CompleteResetCredentialRequest
{
ResetToken = _code,
- NewSecret = _newPassword ?? string.Empty,
+ NewSecret = _newPassword,
Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful
};
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.Designer.cs
similarity index 98%
rename from samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs
rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.Designer.cs
index 7df8d5ae..0f98bcf8 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.Designer.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.Designer.cs
@@ -11,8 +11,8 @@
namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations
{
[DbContext(typeof(UAuthDbContext))]
- [Migration("20261004164018_001_Initial")]
- partial class _001_Initial
+ [Migration("20261009201715_001_InitUAuth")]
+ partial class _001_InitUAuth
{
///
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -572,7 +572,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.HasIndex("Tenant", "UserKey", "IsPrimary");
- b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary");
+ b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id");
b.ToTable("UAuth_UserIdentifiers", (string)null);
});
@@ -617,6 +617,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.HasIndex("Tenant", "UserKey")
.IsUnique();
+ b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id");
+
+ b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id");
+
b.ToTable("UAuth_UserLifecycles", (string)null);
});
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.cs
similarity index 97%
rename from samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs
rename to samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.cs
index 0d43c8e2..e55065bd 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261004164018_001_Initial.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20261009201715_001_InitUAuth.cs
@@ -6,7 +6,7 @@
namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations
{
///
- public partial class _001_Initial : Migration
+ public partial class _001_InitUAuth : Migration
{
///
protected override void Up(MigrationBuilder migrationBuilder)
@@ -492,9 +492,19 @@ protected override void Up(MigrationBuilder migrationBuilder)
columns: new[] { "Tenant", "UserKey", "IsPrimary" });
migrationBuilder.CreateIndex(
- name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary",
+ name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary_DeletedAt_CreatedAt_Id",
table: "UAuth_UserIdentifiers",
- columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary" });
+ columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UAuth_UserLifecycles_Tenant_DeletedAt_CreatedAt_Id",
+ table: "UAuth_UserLifecycles",
+ columns: new[] { "Tenant", "DeletedAt", "CreatedAt", "Id" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UAuth_UserLifecycles_Tenant_Status_DeletedAt_CreatedAt_Id",
+ table: "UAuth_UserLifecycles",
+ columns: new[] { "Tenant", "Status", "DeletedAt", "CreatedAt", "Id" });
migrationBuilder.CreateIndex(
name: "IX_UAuth_UserLifecycles_Tenant_UserKey",
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs
index 65798d28..3e5045d5 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs
@@ -569,7 +569,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("Tenant", "UserKey", "IsPrimary");
- b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary");
+ b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary", "DeletedAt", "CreatedAt", "Id");
b.ToTable("UAuth_UserIdentifiers", (string)null);
});
@@ -614,6 +614,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("Tenant", "UserKey")
.IsUnique();
+ b.HasIndex("Tenant", "DeletedAt", "CreatedAt", "Id");
+
+ b.HasIndex("Tenant", "Status", "DeletedAt", "CreatedAt", "Id");
+
b.ToTable("UAuth_UserLifecycles", (string)null);
});
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm
index 9f70b715..7ce3780f 100644
Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm differ
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal
index 9f632717..6e7926c1 100644
Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal differ
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs
index d2186593..43b08720 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs
@@ -92,11 +92,11 @@ private async Task SaveAsync()
if (UserKey is null)
{
- result = await UAuthClient.Users.UpdateMeAsync(request);
+ result = await UAuthClient.Users.UpdateMyProfileAsync(request);
}
else
{
- result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request);
+ result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request);
}
if (result.IsSuccess)
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
index d9f06ffa..73d6ebd3 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs
@@ -161,7 +161,7 @@ private void HandleTry(IUAuthTryResult result)
{
if (result is TryLoginResult pkce)
{
- if (!result.Success)
+ if (!result.IsSuccess)
{
if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
{
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs
index 681ff884..f02691c1 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs
@@ -25,10 +25,16 @@ private async Task ResetPasswordAsync()
return;
}
+ if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier))
+ {
+ Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error);
+ return;
+ }
+
var request = new CompleteResetCredentialRequest
{
ResetToken = _code,
- NewSecret = _newPassword ?? string.Empty,
+ NewSecret = _newPassword,
Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful
};
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs
index d26cbda0..6acf2d76 100644
--- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs
+++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs
@@ -64,6 +64,7 @@
builder.Services.AddUltimateAuthSampleSeed();
+// Customize UltimateAuth
if (builder.Environment.IsDevelopment())
{
builder.Services.AddScoped();
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs
index c96497e8..8fd1ba9d 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs
@@ -94,11 +94,11 @@ private async Task SaveAsync()
if (UserKey is null)
{
- result = await UAuthClient.Users.UpdateMeAsync(request);
+ result = await UAuthClient.Users.UpdateMyProfileAsync(request);
}
else
{
- result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request);
+ result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request);
}
if (result.IsSuccess)
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
index 78129102..3250a476 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs
@@ -101,7 +101,7 @@ private async Task ProgrammaticLogin()
private async Task HandleLoginResult(IUAuthTryResult result)
{
- if (!result.Success)
+ if (!result.IsSuccess)
{
if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
{
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs
index 43e7b541..231c8115 100644
--- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs
+++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs
@@ -25,10 +25,16 @@ private async Task ResetPasswordAsync()
return;
}
+ if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier))
+ {
+ Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error);
+ return;
+ }
+
var request = new CompleteResetCredentialRequest
{
ResetToken = _code,
- NewSecret = _newPassword ?? string.Empty,
+ NewSecret = _newPassword,
Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful
};
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs
index 50a8d74a..611e7141 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Components/Dialogs/ProfileDialog.razor.cs
@@ -94,11 +94,11 @@ private async Task SaveAsync()
if (UserKey is null)
{
- result = await UAuthClient.Users.UpdateMeAsync(request);
+ result = await UAuthClient.Users.UpdateMyProfileAsync(request);
}
else
{
- result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request);
+ result = await UAuthClient.Users.UpdateUserProfileAsync(UserKey.Value, request);
}
if (result.IsSuccess)
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs
index 2cb3f032..616df5f2 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs
@@ -101,7 +101,7 @@ private async Task ProgrammaticLogin()
private async Task HandleLoginResult(IUAuthTryResult result)
{
- if (!result.Success)
+ if (!result.IsSuccess)
{
if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until)
{
diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs
index be79c63a..401d8656 100644
--- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs
+++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs
@@ -25,11 +25,17 @@ private async Task ResetPasswordAsync()
return;
}
+ if (string.IsNullOrEmpty(_code) || string.IsNullOrEmpty(_newPassword) || string.IsNullOrEmpty(Identifier))
+ {
+ Snackbar.Add("Missing required information. Please ensure you have a valid reset code and new password.", Severity.Error);
+ return;
+ }
+
var request = new CompleteResetCredentialRequest
{
ResetToken = _code,
- NewSecret = _newPassword ?? string.Empty,
- Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful
+ NewSecret = _newPassword,
+ Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful
};
var result = await UAuthClient.Credentials.CompleteResetMyAsync(request);
diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs
new file mode 100644
index 00000000..7846f35d
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/IUAuthPaginationPolicy.cs
@@ -0,0 +1,7 @@
+namespace CodeBeam.UltimateAuth.Core.Abstractions;
+
+public interface IUAuthPaginationPolicy
+{
+ int DefaultPageSize { get; }
+ int MaxPageSize { get; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs
index 4f3812bf..7b0e459b 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs
@@ -14,5 +14,5 @@ public sealed record AuthIdentitySnapshot
public DateTimeOffset? AuthenticatedAt { get; init; }
public SessionState? SessionState { get; init; }
public string? TimeZone { get; init; }
- public UserStatus UserStatus { get; set; }
+ public UserStatus UserStatus { get; init; }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs
index 7fa62828..0ea44e81 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs
@@ -27,6 +27,10 @@ public sealed class AccessContext
public bool HasActor => ActorUserKey != null;
public bool HasTarget => TargetUserKey != null;
+ public bool IsAdminContext => Action.EndsWith(".admin", StringComparison.Ordinal);
+ public bool IsSelfContext => Action.EndsWith(".self", StringComparison.Ordinal);
+ public bool IsAnonymousContext => Action.EndsWith(".anonymous", StringComparison.Ordinal);
+
public UserKey GetTargetUserKey()
{
if (TargetUserKey is not UserKey targetUserKey)
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs
index fa89076e..d0b27ae9 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs
@@ -19,7 +19,7 @@ public static AccessDecision Allow()
requiresReauthentication: false,
denyReason: null);
- public static AccessDecision Deny(string reason)
+ public static AccessDecision Deny(string? reason)
=> new(
isAllowed: false,
requiresReauthentication: false,
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs
index 0424ad16..d4b6799c 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs
@@ -6,7 +6,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed record AuthContext
{
- public UAuthClientProfile ClientProfile { get; set; }
+ public UAuthClientProfile ClientProfile { get; init; }
public TenantKey Tenant { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs
index 1fee5de4..daeda5ba 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs
@@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
public interface IUAuthTryResult
{
- bool Success { get; }
+ bool IsSuccess { get; }
AuthFailureReason? Reason { get; }
int? RemainingAttempts { get; }
DateTimeOffset? LockoutUntilUtc { get; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs
index 1dc6aed7..4dd85c89 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs
@@ -1,30 +1,29 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.Options;
+
+namespace CodeBeam.UltimateAuth.Core.Contracts;
public record PageRequest
{
public int PageNumber { get; init; } = 1;
- public int PageSize { get; init; } = 250;
+ public int PageSize { get; init; } = 0;
public string? SortBy { get; init; }
public bool Descending { get; init; }
- public int MaxPageSize { get; init; } = 1000;
-
- public PageRequest Normalize()
+ public PageRequest Normalize(IUAuthPaginationPolicy policy)
{
- var page = PageNumber <= 0 ? 1 : PageNumber;
- var size = PageSize <= 0 ? 250 : PageSize;
+ ArgumentNullException.ThrowIfNull(policy);
+
+ var page = Math.Max(1, PageNumber);
+ var size = PageSize <= 0 ? policy.DefaultPageSize : PageSize;
- if (size > MaxPageSize)
- size = MaxPageSize;
+ size = Math.Min(size, policy.MaxPageSize);
- return new PageRequest
+ return this with
{
PageNumber = page,
- PageSize = size,
- SortBy = SortBy,
- Descending = Descending,
- MaxPageSize = MaxPageSize
+ PageSize = size
};
}
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs
index 854eafa4..bfe06bcf 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs
@@ -9,7 +9,7 @@ public sealed class PagedResult
public string? SortBy { get; init; }
public bool Descending { get; init; }
- public bool HasNext => PageNumber * PageSize < TotalCount;
+ public bool HasNext => (long)PageNumber * PageSize < TotalCount;
public PagedResult(IReadOnlyList items, int totalCount, int pageNumber, int pageSize, string? sortBy, bool descending)
{
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs
index 64f2be08..6fa7a402 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs
@@ -1,10 +1,10 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.Domain;
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record ExternalLoginRequest
-{
- public required string Provider { get; init; }
- public required string ExternalToken { get; init; }
- public required DeviceContext Device { get; init; }
-}
+//public sealed record ExternalLoginRequest
+//{
+// public required string Provider { get; init; }
+// public required string ExternalToken { get; init; }
+// public required DeviceContext Device { get; init; }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
index ae45a071..5ee258d0 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs
@@ -6,7 +6,7 @@ public sealed record LoginRequest
{
public string Identifier { get; init; } = default!;
public string Secret { get; init; } = default!;
- public CredentialType Factor { get; init; } = CredentialType.Password;
+ public CredentialType CredentialType { get; init; } = CredentialType.Password;
public IReadOnlyDictionary? Metadata { get; init; }
///
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs
index 95802a57..0f992517 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs
@@ -2,7 +2,8 @@
public enum LoginStatus
{
- Success = 0,
- RequiresContinuation = 10,
- Failed = 20
+ None = 0,
+ Success = 10,
+ RequiresContinuation = 20,
+ Failed = 30
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs
index 9ffe9ab8..d8fae096 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs
@@ -1,9 +1,9 @@
-using CodeBeam.UltimateAuth.Core.Domain;
+//using CodeBeam.UltimateAuth.Core.Domain;
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record ReauthRequest
-{
- public AuthSessionId SessionId { get; init; }
- public required string Secret { get; init; }
-}
+//public sealed record ReauthRequest
+//{
+// public AuthSessionId SessionId { get; init; }
+// public required string Secret { get; init; }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs
index a047ff1d..45df39b7 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs
@@ -1,6 +1,6 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record ReauthResult
-{
- public bool Success { get; init; }
-}
+//public sealed record ReauthResult
+//{
+// public bool Success { get; init; }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs
index f91c14ab..ce08a546 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs
@@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed record TryLoginResult : IUAuthTryResult
{
- public bool Success { get; init; }
+ public bool IsSuccess { get; init; }
public AuthFailureReason? Reason { get; init; }
public int? RemainingAttempts { get; init; }
public DateTimeOffset? LockoutUntilUtc { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs
index 052ef9f6..fc3cb8b2 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs
@@ -4,12 +4,6 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed record LogoutAllRequest
{
- ///
- /// The current session initiating the logout-all operation.
- /// Used to resolve the active chain when ExceptCurrent is true.
- ///
- public AuthSessionId? CurrentSessionId { get; init; }
-
///
/// If true, the current session will NOT be revoked.
///
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs
index 00cd98ba..c34c89d4 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs
@@ -2,5 +2,5 @@
public sealed record LogoutResponse
{
- public bool Success { get; init; }
+ public bool IsSuccess { get; init; }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs
index 536ed568..7ad8bab9 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs
@@ -1,6 +1,6 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record BeginMfaRequest
-{
- public required string MfaToken { get; init; }
-}
+//public sealed record BeginMfaRequest
+//{
+// public required string MfaToken { get; init; }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs
index 7aa5d7f0..b99132d5 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs
@@ -1,7 +1,7 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record CompleteMfaRequest
-{
- public required string ChallengeId { get; init; }
- public required string Code { get; init; }
-}
+//public sealed record CompleteMfaRequest
+//{
+// public required string ChallengeId { get; init; }
+// public required string Code { get; init; }
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs
index c0a4aad6..9cd945ee 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs
@@ -1,7 +1,7 @@
-namespace CodeBeam.UltimateAuth.Core.Contracts;
+//namespace CodeBeam.UltimateAuth.Core.Contracts;
-public sealed record MfaChallengeResult
-{
- public required string ChallengeId { get; init; }
- public required string Method { get; init; } // totp, sms, email etc.
-}
+//public sealed record MfaChallengeResult
+//{
+// public required string ChallengeId { get; init; }
+// public required string Method { get; init; } // totp, sms, email etc.
+//}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs
index bc16b023..2b584d58 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs
@@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed record PkceCompleteResult
{
- public bool Success { get; init; }
+ public bool IsSuccess { get; init; }
public AuthFailureReason? FailureReason { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs
index aef8a634..6efd7351 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs
@@ -4,7 +4,7 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed record TryPkceLoginResult : IUAuthTryResult
{
- public bool Success { get; init; }
+ public bool IsSuccess { get; init; }
public AuthFailureReason? Reason { get; init; }
public int? RemainingAttempts { get; init; }
public DateTimeOffset? LockoutUntilUtc { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs
index 59b18af1..68cb5656 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs
@@ -4,7 +4,12 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed class RefreshFlowResult
{
- public bool Succeeded { get; init; }
+ public bool IsSuccess =>
+ Outcome is RefreshOutcome.Success
+ or RefreshOutcome.NoOp
+ or RefreshOutcome.Touched
+ or RefreshOutcome.Rotated;
+
public RefreshOutcome Outcome { get; init; }
public AuthSessionId? SessionId { get; init; }
@@ -15,7 +20,6 @@ public static RefreshFlowResult ReauthRequired()
{
return new RefreshFlowResult
{
- Succeeded = false,
Outcome = RefreshOutcome.ReauthRequired
};
}
@@ -26,9 +30,14 @@ public static RefreshFlowResult Success(
AccessToken? accessToken = null,
RefreshTokenInfo? refreshToken = null)
{
+
+ if (outcome is not (RefreshOutcome.Success or RefreshOutcome.NoOp or RefreshOutcome.Touched or RefreshOutcome.Rotated))
+ {
+ throw new ArgumentOutOfRangeException(nameof(outcome));
+ }
+
return new RefreshFlowResult
{
- Succeeded = true,
Outcome = outcome,
SessionId = sessionId,
AccessToken = accessToken,
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs
index bc4d9c9e..ec6a0c54 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs
@@ -9,11 +9,11 @@ public sealed record AuthValidationResult
{
public required SessionState State { get; init; }
public AuthStateSnapshot? Snapshot { get; init; }
- public Guid? ChainId { get; init; }
+ public SessionChainId? ChainId { get; init; }
- public Guid? RootId { get; init; }
+ public SessionRootId? RootId { get; init; }
- public string? BoundDeviceId { get; init; }
+ public DeviceId? BoundDeviceId { get; init; }
public bool IsValid => State == SessionState.Active;
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
index 9443abab..2ff5672f 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs
@@ -51,7 +51,7 @@ public static SessionValidationResult Active(
public static SessionValidationResult Invalid(
SessionState state,
- UserKey? userId = null,
+ UserKey? userKey = null,
AuthSessionId? sessionId = null,
SessionChainId? chainId = null,
SessionRootId? rootId = null,
@@ -59,7 +59,7 @@ public static SessionValidationResult Invalid(
=> new()
{
State = state,
- UserKey = userId,
+ UserKey = userKey,
SessionId = sessionId,
ChainId = chainId,
RootId = rootId,
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs
index 6d5648b5..7e389170 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs
@@ -1,22 +1,22 @@
namespace CodeBeam.UltimateAuth.Core.Contracts;
///
-/// Transport model for refresh token. Returned to client once upon creation.
+/// Represents refresh token information intended for client-facing authentication results.
///
+///
+/// Contains only the raw refresh token and its expiration time.
+/// Server-side security and persistence details, such as the token hash are intentionally excluded.
+/// Actual token delivery is governed by the configured response policy.
+///
public sealed class RefreshTokenInfo
{
///
- /// Plain refresh token value (returned to client once).
+ /// Gets the raw refresh token to be delivered to the client through the configured delivery mechanism.
///
public required string Token { get; init; }
///
- /// Hash of the refresh token to be persisted.
- ///
- public required string TokenHash { get; init; }
-
- ///
- /// Expiration time.
+ /// Gets the expiration time of the refresh token in UTC.
///
public required DateTimeOffset ExpiresAt { get; init; }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs
new file mode 100644
index 00000000..b3b461e4
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenIssuanceResult.cs
@@ -0,0 +1,30 @@
+namespace CodeBeam.UltimateAuth.Core.Contracts;
+
+///
+/// Represents the result of issuing a refresh token for trusted server-side operations.
+///
+///
+/// Contains both the raw refresh token and its hash.
+/// The raw token is intended for one-time delivery to the client,
+/// while the hash is used exclusively for server-side persistence and rotation.
+/// This type is not intended to be exposed directly in client responses.
+/// Use for client-facing token information.
+///
+public sealed class RefreshTokenIssuanceResult
+{
+ ///
+ /// Gets the raw refresh token generated for one-time delivery to the client.
+ ///
+ public required string Token { get; init; }
+
+ ///
+ /// Gets the hash of the refresh token used for server-side persistence, validation, and rotation.
+ /// Must never be exposed to the client.
+ ///
+ public required string TokenHash { get; init; }
+
+ ///
+ /// Gets the expiration time of the refresh token in UTC.
+ ///
+ public required DateTimeOffset ExpiresAt { get; init; }
+}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs
index af715f12..1e190647 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs
@@ -6,9 +6,7 @@ public sealed record RefreshTokenRotationResult
{
public bool IsSuccess { get; init; }
public bool ReauthRequired { get; init; }
- public bool IsReuseDetected { get; init; } // internal use
- public AuthSessionId? SessionId { get; init; }
public AccessToken? AccessToken { get; init; }
public RefreshTokenInfo? RefreshToken { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs
index fb95004f..91f1cd5a 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs
@@ -6,9 +6,8 @@ namespace CodeBeam.UltimateAuth.Core.Contracts;
public sealed record TokenIssuanceContext
{
public required UserKey UserKey { get; init; }
- public TenantKey Tenant { get; init; }
- public IReadOnlyDictionary Claims { get; set; } = new Dictionary();
+ public required TenantKey Tenant { get; init; }
+ public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty;
public AuthSessionId? SessionId { get; init; }
public SessionChainId? ChainId { get; init; }
- public DateTimeOffset IssuedAt { get; init; }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs
index 78bd7e1c..687acf39 100644
--- a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs
@@ -4,7 +4,7 @@
//public sealed class UserAuthenticationResult
//{
-// public bool Succeeded { get; init; }
+// public bool IsSuccess { get; init; }
// public TUserId? UserId { get; init; }
@@ -12,12 +12,12 @@
// public bool RequiresMfa { get; init; }
-// public static UserAuthenticationResult Fail() => new() { Succeeded = false };
+// public static UserAuthenticationResult Fail() => new() { IsSuccess = false };
// public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false)
// => new()
// {
-// Succeeded = true,
+// IsSuccess = true,
// UserId = userId,
// Claims = claims,
// RequiresMfa = requiresMfa
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs
index a95107d6..9fac6f84 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/PasswordHash.cs
@@ -1,6 +1,10 @@
using CodeBeam.UltimateAuth.Core.Errors;
+using CodeBeam.UltimateAuth.Core.Infrastructure;
+using System.Text.Json.Serialization;
namespace CodeBeam.UltimateAuth.Core;
+
+[JsonConverter(typeof(PasswordHashJsonConverter))]
public readonly record struct PasswordHash : IParsable
{
public string Algorithm { get; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs
index 4bc6988f..7d28fd67 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs
@@ -14,17 +14,7 @@ private AuthSessionId(string value)
Value = value;
}
- public static bool TryCreate(string? raw, out AuthSessionId id)
- {
- if (IsValid(raw))
- {
- id = new AuthSessionId(raw!);
- return true;
- }
-
- id = default;
- return false;
- }
+ public static bool TryCreate(string? raw, out AuthSessionId id) => TryParse(raw, null, out id);
public static AuthSessionId Parse(string s, IFormatProvider? provider)
{
@@ -34,6 +24,8 @@ public static AuthSessionId Parse(string s, IFormatProvider? provider)
throw new FormatException("Invalid AuthSessionId.");
}
+ public static AuthSessionId Parse(string value) => Parse(value, null);
+
public static bool TryParse(string? s, IFormatProvider? provider, out AuthSessionId result)
{
if (IsValid(s))
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs
index 99ff48b2..7e1d1df8 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs
@@ -1,4 +1,5 @@
-using System.Security.Claims;
+using System.Collections.ObjectModel;
+using System.Security.Claims;
using System.Text.Json.Serialization;
namespace CodeBeam.UltimateAuth.Core.Domain;
@@ -21,7 +22,25 @@ public sealed class ClaimsSnapshot
[JsonConstructor]
public ClaimsSnapshot(IReadOnlyDictionary> claims)
{
- _claims = claims;
+ ArgumentNullException.ThrowIfNull(claims);
+ var copy = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var (type, values) in claims)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(type);
+ ArgumentNullException.ThrowIfNull(values);
+
+ var items = values.ToArray();
+
+ if (items.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new ArgumentException("Claim values cannot be null or whitespace.", nameof(claims));
+ }
+
+ copy.Add(type, Array.AsReadOnly(items));
+ }
+
+ _claims = new ReadOnlyDictionary>(copy);
}
public static ClaimsSnapshot Empty { get; } = new(new Dictionary>());
@@ -99,11 +118,11 @@ public override int GetHashCode()
{
int hash = 17;
- foreach (var (type, values) in Claims.OrderBy(x => x.Key))
+ foreach (var (type, values) in Claims.OrderBy(x => x.Key, StringComparer.Ordinal))
{
hash = hash * 23 + type.GetHashCode();
- foreach (var value in values.OrderBy(v => v))
+ foreach (var value in values.OrderBy(v => v, StringComparer.Ordinal))
{
hash = hash * 23 + value.GetHashCode();
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs
index da64faf0..a06626c0 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs
@@ -2,9 +2,10 @@
public enum RefreshOutcome
{
- Success = 0, // minimal transport
- NoOp = 10,
- Touched = 20,
- Rotated = 30,
+ None = 0,
+ Success = 10, // minimal transport
+ NoOp = 20,
+ Touched = 30,
+ Rotated = 40,
ReauthRequired = 100
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs
deleted file mode 100644
index 9099cbf6..00000000
--- a/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace CodeBeam.UltimateAuth.Core.Domain;
-
-///
-/// Represents the minimal user abstraction required by UltimateAuth.
-/// Includes the unique user identifier and an optional set of claims that
-/// may be used during authentication or session creation.
-///
-public interface IAuthSubject
-{
- ///
- /// Gets the unique identifier of the user.
- ///
- TUserId UserId { get; }
-
- ///
- /// Gets an optional collection of user claims that may be used to construct
- /// session-level claim snapshots. Implementations may return null if no claims are available.
- ///
- IReadOnlyDictionary? Claims { get; }
-}
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs
index e45d5220..83110647 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs
@@ -43,12 +43,6 @@ public static bool TryParse(string? s, IFormatProvider? provider, out UserKey re
return false;
}
- if (Guid.TryParse(s, out var guid))
- {
- result = FromGuid(guid);
- return true;
- }
-
result = FromString(s);
return true;
}
@@ -61,6 +55,8 @@ public static UserKey Parse(string s, IFormatProvider? provider)
return result;
}
+ public static UserKey Parse(string value) => Parse(value, null);
+
public override string ToString() => Value;
public static implicit operator string(UserKey key) => key.Value;
diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs
index 8629eb13..369b85c5 100644
--- a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs
@@ -1,5 +1,23 @@
namespace CodeBeam.UltimateAuth.Core.Domain;
+// TODO (User Management Expansion):
+// UltimateAuth must not require host applications to inherit from framework
+// user types or implement interfaces on their existing user entities.
+//
+// Host applications should integrate their user models through dedicated
+// adapter/provider interfaces registered via dependency injection.
+//
+// Future User Management Expansion work should define and document these
+// integration contracts, including user resolution, runtime state mapping,
+// lifecycle integration, and claims provisioning.
+//
+// Keep host user entities independent from UltimateAuth contracts.
+// Do not introduce a mandatory IUser or IAuthSubject dependency.
+
+///
+/// Represents the minimal user state required by UltimateAuth at runtime.
+/// This is a framework-owned snapshot, not a host application user entity.
+///
public sealed record UserRuntimeRecord
{
public UserKey UserKey { get; init; }
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs
index 95299f6c..37b72535 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs
@@ -8,6 +8,9 @@ public sealed class DeviceIdJsonConverter : JsonConverter
{
public override DeviceId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
+ if (reader.TokenType != JsonTokenType.String)
+ throw new JsonException("DeviceId must be a string.");
+
var value = reader.GetString();
if (!DeviceId.TryCreate(value, out var id))
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs
index 24c3c08f..53864272 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs
@@ -9,12 +9,12 @@ public sealed class SessionRootIdJsonConverter : JsonConverter
public override SessionRootId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.String)
- throw new JsonException("SessionChainId must be a string.");
+ throw new JsonException("SessionRootId must be a string.");
var raw = reader.GetString();
if (!SessionRootId.TryCreate(raw!, out var id))
- throw new JsonException($"Invalid SessionChainId value: '{raw}'");
+ throw new JsonException($"Invalid SessionRootId value: '{raw}'");
return id;
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs
index b0d42356..1f2922a2 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs
@@ -19,7 +19,14 @@ public override TenantKey Read(ref Utf8JsonReader reader, Type typeToConvert, Js
// IMPORTANT:
// JSON transport = internal framework boundary
// Do NOT use FromExternal here.
- return TenantKey.FromInternal(value);
+ try
+ {
+ return TenantKey.FromInternal(value!);
+ }
+ catch (ArgumentException ex)
+ {
+ throw new JsonException("Invalid TenantKey.", ex);
+ }
}
public override void Write(Utf8JsonWriter writer, TenantKey value, JsonSerializerOptions options)
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs
index 0a4da433..e7bb5742 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs
@@ -14,7 +14,12 @@ public override UserKey Read(ref Utf8JsonReader reader, Type typeToConvert, Json
if (reader.TokenType != JsonTokenType.String)
throw new JsonException("UserKey must be a string.");
- return UserKey.FromString(reader.GetString()!);
+ var value = reader.GetString();
+
+ if (string.IsNullOrWhiteSpace(value))
+ throw new JsonException("UserKey cannot be null or empty.");
+
+ return UserKey.FromString(value);
}
public override void Write(Utf8JsonWriter writer, UserKey value, JsonSerializerOptions options)
diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs
index bb4458f2..d935f685 100644
--- a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs
@@ -14,52 +14,30 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes
return SessionValidationResult.Invalid(
dto.State,
sessionId: sessionId,
- chainId: TryParseChainId(dto.ChainId),
- rootId: TryParseRootId(dto.RootId),
- boundDeviceId: TryParseDeviceId(dto.BoundDeviceId));
+ chainId: dto.ChainId,
+ rootId: dto.RootId,
+ boundDeviceId: dto.BoundDeviceId);
}
- //
- // Active is a stronger contract than merely receiving
- // a successful HTTP response. All required security
- // lineage and identity data must be present.
- //
+ // An active validation result must contain
+ // the required identity and security lineage.
if (dto.Snapshot?.Identity is null)
{
- return SessionValidationResult.Invalid(
- SessionState.Invalid,
- sessionId: sessionId);
+ return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId);
}
- if (dto.ChainId is not Guid chainGuid ||
- chainGuid == Guid.Empty)
+ if (dto.ChainId is not SessionChainId chainId || chainId.IsUnassigned)
{
- return SessionValidationResult.Invalid(
- SessionState.Invalid,
- sessionId: sessionId);
+ return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId);
}
- var chainId =
- SessionChainId.From(chainGuid);
-
- if (dto.RootId is not Guid rootGuid ||
- rootGuid == Guid.Empty)
+ if (dto.RootId is not SessionRootId rootId || rootId.Value == Guid.Empty)
{
- return SessionValidationResult.Invalid(
- SessionState.Invalid,
- sessionId: sessionId,
- chainId: chainId);
+ return SessionValidationResult.Invalid(SessionState.Invalid, sessionId: sessionId, chainId: chainId);
}
- var rootId =
- SessionRootId.From(rootGuid);
-
- var identity =
- dto.Snapshot.Identity;
-
- var boundDeviceId =
- TryParseDeviceId(dto.BoundDeviceId);
+ var identity = dto.Snapshot.Identity;
return SessionValidationResult.Active(
tenant: identity.Tenant,
@@ -68,10 +46,8 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes
chainId: chainId,
rootId: rootId,
claims: dto.Snapshot.Claims,
- authenticatedAt:
- identity.AuthenticatedAt
- ?? DateTimeOffset.UtcNow,
- boundDeviceId: boundDeviceId);
+ authenticatedAt: identity.AuthenticatedAt ?? DateTimeOffset.UtcNow,
+ boundDeviceId: dto.BoundDeviceId);
}
public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result)
@@ -88,35 +64,4 @@ public static SessionValidationResult ToDomain(AuthValidationResult dto, AuthSes
BoundDeviceId = result.BoundDeviceId
};
}
-
- private static SessionChainId? TryParseChainId(Guid? value)
- {
- if (value is not Guid guid ||
- guid == Guid.Empty)
- {
- return null;
- }
-
- return SessionChainId.From(guid);
- }
-
- private static SessionRootId? TryParseRootId(Guid? value)
- {
- if (value is not Guid guid ||
- guid == Guid.Empty)
- {
- return null;
- }
-
- return SessionRootId.From(guid);
- }
-
- private static DeviceId? TryParseDeviceId(string? value)
- {
- return DeviceId.TryCreate(
- value,
- out var id)
- ? id
- : null;
- }
}
diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs
index 949dc694..1e44eca7 100644
--- a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs
+++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs
@@ -24,11 +24,12 @@ private TenantKey(string value)
public bool IsSingle => Value == Single.Value;
public bool IsSystem => Value == System.Value;
public bool IsUnresolved => Value == Unresolved.Value;
+ public bool IsValid => !string.IsNullOrWhiteSpace(Value);
///
/// True only for real, customer-defined tenants.
///
- public bool IsNormal => !IsSingle && !IsSystem && !IsUnresolved;
+ public bool IsNormal => IsValid && !IsSingle && !IsSystem && !IsUnresolved;
public static TenantKey Parse(string s, IFormatProvider? provider)
{
@@ -38,6 +39,8 @@ public static TenantKey Parse(string s, IFormatProvider? provider)
return result;
}
+ public static TenantKey Parse(string value) => Parse(value, null);
+
public static bool TryParse(string? s, IFormatProvider? provider, out TenantKey result)
{
result = default;
@@ -50,7 +53,11 @@ public static bool TryParse(string? s, IFormatProvider? provider, out TenantKey
result = FromExternal(s);
return true;
}
- catch
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ catch (SecurityException)
{
return false;
}
@@ -78,7 +85,15 @@ public static TenantKey FromExternal(string? value)
///
/// Internal creation for framework use only.
///
- internal static TenantKey FromInternal(string value) => new(value);
+ internal static TenantKey FromInternal(string value)
+ {
+ if (value == Single.Value || value == System.Value || value == Unresolved.Value)
+ {
+ return new TenantKey(value);
+ }
+
+ return new TenantKey(Normalize(value));
+ }
private static string Normalize(string value)
{
diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs
index ac273a50..69702225 100644
--- a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs
+++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs
@@ -47,4 +47,9 @@ public sealed class UAuthOptions
/// validated, and optionally enforced.
///
public UAuthMultiTenantOptions MultiTenant { get; set; } = new();
+
+ ///
+ /// Pagination configuration for user queries, role listings, and other paged endpoints.
+ ///
+ public UAuthPaginationOptions Pagination { get; set; } = new();
}
diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs
new file mode 100644
index 00000000..f88e98dc
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPaginationOptions.cs
@@ -0,0 +1,19 @@
+using CodeBeam.UltimateAuth.Core.Abstractions;
+
+namespace CodeBeam.UltimateAuth.Core.Options;
+
+public sealed class UAuthPaginationOptions : IUAuthPaginationPolicy
+{
+ public const int DefaultPageSizeValue = 250;
+ public const int DefaultMaxPageSizeValue = 1000;
+
+ public int DefaultPageSize { get; set; } = DefaultPageSizeValue;
+
+ public int MaxPageSize { get; set; } = DefaultMaxPageSizeValue;
+
+ internal UAuthPaginationOptions Clone() => new()
+ {
+ DefaultPageSize = DefaultPageSize,
+ MaxPageSize = MaxPageSize
+ };
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs
index 9b525f5f..106a7ec7 100644
--- a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs
@@ -1,7 +1,7 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Server.Auth;
-namespace CodeBeam.UltimateAuth.Server.Abstactions;
+namespace CodeBeam.UltimateAuth.Server.Abstractions;
///
/// Issues access and refresh tokens according to the active auth mode.
@@ -10,5 +10,5 @@ namespace CodeBeam.UltimateAuth.Server.Abstactions;
public interface ITokenIssuer
{
Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default);
- Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default);
+ Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs
index 50bd4e05..acef799a 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs
@@ -35,7 +35,7 @@ public sealed class AuthFlowContext
Response.RefreshTokenDelivery.Mode != TokenResponseMode.None;
public bool IsSingleTenant => Tenant.IsSingle;
- public bool IsMultiTenant => !Tenant.IsSingle && !Tenant.IsSystem;
+ public bool IsMultiTenant => Tenant.IsNormal;
internal AuthFlowContext(
AuthFlowType flowType,
@@ -52,8 +52,8 @@ internal AuthFlowContext(
PrimaryTokenKind primaryTokenKind,
ReturnUrlInfo returnUrlInfo)
{
- if (tenantKey.IsUnresolved)
- throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant.");
+ if (!tenantKey.IsValid || tenantKey.IsUnresolved)
+ throw new InvalidOperationException("AuthFlowContext cannot be created with invalid or unresolved tenant.");
FlowType = flowType;
ClientProfile = clientProfile;
diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
index 3357fd43..0172602d 100644
--- a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs
@@ -2,6 +2,7 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Infrastructure;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Extensions;
@@ -84,8 +85,8 @@ public async ValueTask CreateAsync(HttpContext ctx, AuthFlowTyp
sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation);
}
- if (tenant.IsUnresolved)
- throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant.");
+ if (!tenant.IsValid || tenant.IsUnresolved)
+ throw new InvalidOperationException("AuthFlowContext cannot be created with invalid or unresolved tenant.");
// TODO: Implement invariant checker
//_invariantChecker.Validate(flowType, effectiveMode, response, effectiveOptions);
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs
index 5d6b0885..f4a1cb59 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs
@@ -92,7 +92,7 @@ public async Task LoginAsync(HttpContext ctx)
{
Identifier = request.Identifier,
Secret = request.Secret,
- Factor = CredentialType.Password,
+ CredentialType = CredentialType.Password,
PreviewReceipt = request.PreviewReceipt,
RequestTokens = authFlow.AllowsTokenIssuance,
Metadata = request.Metadata,
@@ -152,7 +152,7 @@ public async Task TryLoginAsync(HttpContext ctx)
{
return Results.Ok(new TryLoginResult
{
- Success = false,
+ IsSuccess = false,
Reason = AuthFailureReason.InvalidCredentials
});
}
@@ -161,7 +161,7 @@ public async Task TryLoginAsync(HttpContext ctx)
{
Identifier = request.Identifier,
Secret = request.Secret,
- Factor = CredentialType.Password,
+ CredentialType = CredentialType.Password,
PreviewReceipt = request.PreviewReceipt,
RequestTokens = authFlow.AllowsTokenIssuance,
Metadata = request.Metadata
@@ -185,7 +185,7 @@ public async Task TryLoginAsync(HttpContext ctx)
var fingerprint = LoginPreviewFingerprint.Create(
authFlow.Tenant,
request.Identifier,
- request.Factor,
+ request.CredentialType,
request.Secret,
deviceId);
@@ -199,7 +199,7 @@ public async Task TryLoginAsync(HttpContext ctx)
var artifact = new LoginPreviewArtifact(
authFlow.Tenant,
userKey,
- request.Factor,
+ request.CredentialType,
deviceId.Value,
request.Identifier,
authFlow.ClientProfile,
@@ -212,7 +212,7 @@ public async Task TryLoginAsync(HttpContext ctx)
return Results.Ok(new TryLoginResult
{
- Success = result.IsSuccess,
+ IsSuccess = result.IsSuccess,
Reason = result.FailureReason,
RemainingAttempts = result.RemainingAttempts,
LockoutUntilUtc = result.LockoutUntilUtc,
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs
index cbaaf154..06f9b4fd 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs
@@ -54,7 +54,7 @@ public async Task LogoutAsync(HttpContext ctx)
return decision.Enabled
? Results.Redirect(decision.TargetUrl!)
- : Results.Ok(new LogoutResponse { Success = true });
+ : Results.Ok(new LogoutResponse { IsSuccess = true });
}
public async Task LogoutDeviceSelfAsync(HttpContext ctx)
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
index 768c4013..519dfbe2 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs
@@ -99,7 +99,7 @@ public async Task TryCompleteAsync(HttpContext ctx)
{
return Results.Ok(new TryPkceLoginResult
{
- Success = false,
+ IsSuccess = false,
RetryWithNewPkce = true
});
}
@@ -112,11 +112,11 @@ public async Task TryCompleteAsync(HttpContext ctx)
var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow);
- if (!validation.Success)
+ if (!validation.IsSuccess)
{
return Results.Ok(new TryPkceLoginResult
{
- Success = false,
+ IsSuccess = false,
RetryWithNewPkce = true
});
}
@@ -144,7 +144,7 @@ public async Task TryCompleteAsync(HttpContext ctx)
return Results.Ok(new TryPkceLoginResult
{
- Success = preview.IsSuccess,
+ IsSuccess = preview.IsSuccess,
Reason = preview.FailureReason,
RemainingAttempts = preview.RemainingAttempts,
LockoutUntilUtc = preview.LockoutUntilUtc,
@@ -182,7 +182,7 @@ public async Task CompleteAsync(HttpContext ctx)
if (result.InvalidPkce)
return Results.Unauthorized();
- if (!result.Success)
+ if (!result.IsSuccess)
return await RedirectToLoginWithErrorAsync(ctx, auth, "invalid");
var login = result.LoginResult!;
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs
index bc59d6b0..2d4f37ee 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs
@@ -51,7 +51,7 @@ public async Task RefreshAsync(HttpContext ctx)
var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted);
- if (!result.Succeeded)
+ if (!result.IsSuccess)
{
return Results.Unauthorized();
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
index bbe6fc31..84f22cde 100644
--- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs
@@ -76,9 +76,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
return Results.Ok(new AuthValidationResult
{
State = result.State,
- ChainId = result.ChainId?.Value,
- RootId = result.RootId?.Value,
- BoundDeviceId = result.BoundDeviceId?.Value
+ ChainId = result.ChainId,
+ RootId = result.RootId,
+ BoundDeviceId = result.BoundDeviceId
});
}
@@ -88,9 +88,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
new AuthValidationResult
{
State = SessionState.Invalid,
- ChainId = result.ChainId?.Value,
- RootId = result.RootId?.Value,
- BoundDeviceId = result.BoundDeviceId?.Value
+ ChainId = result.ChainId,
+ RootId = result.RootId,
+ BoundDeviceId = result.BoundDeviceId
},
statusCode: StatusCodes.Status401Unauthorized
);
@@ -104,9 +104,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
new AuthValidationResult
{
State = SessionState.Invalid,
- ChainId = result.ChainId?.Value,
- RootId = result.RootId?.Value,
- BoundDeviceId = result.BoundDeviceId?.Value
+ ChainId = result.ChainId,
+ RootId = result.RootId,
+ BoundDeviceId = result.BoundDeviceId
},
statusCode:
StatusCodes.Status401Unauthorized);
@@ -115,9 +115,9 @@ public async Task ValidateAsync(HttpContext context, CancellationToken
return Results.Ok(new AuthValidationResult
{
State = SessionState.Active,
- ChainId = result.ChainId?.Value,
- RootId = result.RootId?.Value,
- BoundDeviceId = result.BoundDeviceId?.Value,
+ ChainId = result.ChainId,
+ RootId = result.RootId,
+ BoundDeviceId = result.BoundDeviceId,
Snapshot = snapshot
});
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
index dfa550da..d3566994 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs
@@ -13,7 +13,6 @@
using CodeBeam.UltimateAuth.Policies.Abstractions;
using CodeBeam.UltimateAuth.Policies.Defaults;
using CodeBeam.UltimateAuth.Policies.Registry;
-using CodeBeam.UltimateAuth.Server.Abstactions;
using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Authentication;
@@ -63,7 +62,8 @@ public static IServiceCollection AddUltimateAuthServer(this IServiceCollection s
.PostConfigure(options =>
{
// Add any default values or adjustments here if needed
- });
+ })
+ .ValidateOnStart();
services.AddUltimateAuthServerInternal();
@@ -81,7 +81,8 @@ public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollect
{
configure?.Invoke(options);
})
- .BindConfiguration("UltimateAuth:ResourceApi");
+ .BindConfiguration("UltimateAuth:ResourceApi")
+ .ValidateOnStart();
services.AddUltimateAuthResourceInternal();
@@ -133,8 +134,15 @@ private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCol
services.AddSingleton, UAuthServerMultiTenantOptionsValidator>();
services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>();
services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>();
+ services.AddSingleton, UAuthServerPaginationOptionsValidator>();
services.AddScoped();
+ services.TryAddSingleton(sp =>
+ {
+ var options = sp.GetRequiredService>().Value;
+ return options.Pagination;
+ });
+
services.TryAddEnumerable(ServiceDescriptor.Singleton());
services.TryAddEnumerable(ServiceDescriptor.Singleton());
services.TryAddEnumerable(ServiceDescriptor.Singleton());
diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs
index f2c6a3d7..9ba89be2 100644
--- a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs
@@ -27,20 +27,22 @@ public static IApplicationBuilder UseUAuthExceptionHandling(this IApplicationBui
private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeException ex)
{
+ var status = MapStatusCode(ex);
+
var problem = new ProblemDetails
{
Title = ex.Title,
Detail = ex.Code,
- Status = MapStatusCode(ex),
+ Status = status,
Type = $"{ex.TypePrefix}/{ex.Code}"
};
problem.Extensions["traceId"] = context.TraceIdentifier;
- context.Response.StatusCode = problem.Status ?? 500;
+ context.Response.StatusCode = status;
context.Response.ContentType = "application/problem+json";
- return context.Response.WriteAsJsonAsync(problem);
+ return context.Response.WriteAsJsonAsync(problem, options: null, contentType: "application/problem+json");
}
private static int MapStatusCode(UAuthRuntimeException ex) =>
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
index 5c215621..a8b8f377 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs
@@ -6,7 +6,7 @@
using CodeBeam.UltimateAuth.Core.Events;
using CodeBeam.UltimateAuth.Core.Security;
using CodeBeam.UltimateAuth.Credentials;
-using CodeBeam.UltimateAuth.Server.Abstactions;
+using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Extensions;
using CodeBeam.UltimateAuth.Server.Infrastructure;
@@ -71,6 +71,9 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
if (flow.Device.DeviceId is not DeviceId deviceId)
throw new UAuthConflictException("Device id could not resolved.");
+ if (request.CredentialType != CredentialType.Password)
+ throw new UAuthValidationException("unsupported_credential_type");
+
var now = _clock.UtcNow;
var resolution = await _identifierResolver.ResolveAsync(flow.Tenant, request.Identifier, ct);
var userKey = resolution?.UserKey;
@@ -94,7 +97,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0);
}
- factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.Factor, ct);
+ factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.CredentialType, ct);
if (factorState.IsLocked(now))
{
@@ -107,7 +110,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
foreach (var credential in credentials)
{
- if (credential.IsDeleted || !credential.Security.IsUsable(now))
+ if (credential.Type != request.CredentialType || credential.IsDeleted || !credential.Security.IsUsable(now))
continue;
if (await provider.ValidateAsync(credential, request.Secret, ct))
@@ -168,7 +171,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
factorState = await _authenticationSecurityManager.MutateFactorAsync(
flow.Tenant,
userKey.Value,
- request.Factor,
+ request.CredentialType,
state => state.RegisterFailure(
now,
_options.Login.MaxFailedAttempts,
@@ -228,7 +231,7 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
factorState = await _authenticationSecurityManager.MutateFactorAsync(
flow.Tenant,
userKey.Value,
- request.Factor,
+ request.CredentialType,
state => state.RegisterSuccess(),
ct);
}
@@ -260,13 +263,17 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req
UserKey = userKey.Value,
SessionId = issuedSession.Session.SessionId,
ChainId = issuedSession.Session.ChainId,
- Claims = claims.AsDictionary()
+ Claims = claims
};
+ var accessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct);
+
+ var refreshIssuance = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct);
+
tokens = new AuthTokens
{
- AccessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct),
- RefreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct)
+ AccessToken = accessToken,
+ RefreshToken = refreshIssuance is null ? null : new RefreshTokenInfo { Token = refreshIssuance.Token, ExpiresAt = refreshIssuance.ExpiresAt }
};
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs
index a6c0dae9..9573fd35 100644
--- a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs
@@ -4,11 +4,11 @@ public sealed class PkceValidationResult
{
private PkceValidationResult(bool success, PkceValidationFailureReason reason)
{
- Success = success;
+ IsSuccess = success;
FailureReason = reason;
}
- public bool Success { get; }
+ public bool IsSuccess { get; }
public PkceValidationFailureReason FailureReason { get; }
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs
index 9af510d5..ae22cc9e 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs
@@ -57,7 +57,7 @@ public void WriteInternal(HttpContext context, GrantKind kind, string value)
case TokenResponseMode.Body:
// TODO: Implement body writing if needed
- break;
+ throw new NotSupportedException("Body token delivery is not supported.");
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs
index a9ab6d46..b260a9f0 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs
@@ -31,36 +31,33 @@ public string CreateToken(UAuthJwtTokenDescriptor descriptor)
Claims = BuildClaims(descriptor),
- SigningCredentials = new SigningCredentials(
- signingKey.Key,
- signingKey.Algorithm)
+ SigningCredentials = new SigningCredentials(signingKey.Key, signingKey.Algorithm)
};
- tokenDescriptor.AdditionalHeaderClaims = new Dictionary
- {
- ["kid"] = signingKey.KeyId
- };
-
return _handler.CreateToken(tokenDescriptor);
}
private static IDictionary BuildClaims(UAuthJwtTokenDescriptor descriptor)
{
- var claims = new Dictionary
- {
- ["sub"] = descriptor.Subject
- };
-
- claims["tenant"] = descriptor.Tenant;
+ var claims = new Dictionary(StringComparer.Ordinal);
if (descriptor.Claims is not null)
{
- foreach (var kv in descriptor.Claims)
+ foreach (var (type, value) in descriptor.Claims)
{
- claims[kv.Key] = kv.Value;
+ // These claims are controlled by SecurityTokenDescriptor
+ // or explicitly defined by UAuthJwtTokenDescriptor.
+ if (type is "sub" or "tenant" or "iss" or "aud" or "exp" or "nbf" or "iat")
+ continue;
+
+ claims[type] = value;
}
}
+ // Framework-owned identity claims always take precedence.
+ claims["sub"] = descriptor.Subject;
+ claims["tenant"] = descriptor.Tenant.Value;
+
return claims;
}
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs
index c4d139fb..1649494c 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs
@@ -3,7 +3,7 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Options;
-using CodeBeam.UltimateAuth.Server.Abstactions;
+using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
namespace CodeBeam.UltimateAuth.Server.Infrastructure;
@@ -40,7 +40,7 @@ public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuan
{
// TODO: Discuss, Hybrid token may be JWT.
UAuthMode.PureOpaque or UAuthMode.Hybrid =>
- Task.FromResult(IssueOpaqueAccessToken(expires, flow?.Session?.SessionId.ToString())),
+ Task.FromResult(IssueOpaqueAccessToken(expires, context.SessionId)),
UAuthMode.SemiHybrid or
UAuthMode.PureJwt =>
@@ -50,7 +50,7 @@ UAuthMode.SemiHybrid or
};
}
- public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default)
+ public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default)
{
if (flow.EffectiveMode == UAuthMode.PureOpaque)
return null;
@@ -84,7 +84,7 @@ await store.ExecuteAsync(async transactionCt =>
}, ct);
}
- return new RefreshTokenInfo
+ return new RefreshTokenIssuanceResult
{
Token = raw,
TokenHash = hash,
@@ -92,7 +92,7 @@ await store.ExecuteAsync(async transactionCt =>
};
}
- private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId)
+ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, AuthSessionId? sessionId)
{
string token = _opaqueGenerator.Generate();
@@ -101,26 +101,48 @@ private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessi
Token = token,
Format = TokenFormat.Opaque,
ExpiresAt = expires,
- SessionId = sessionId
+ SessionId = sessionId?.Value
};
}
private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthTokenOptions tokens, DateTimeOffset expires)
{
- var claims = new Dictionary();
+ var claims = new Dictionary(StringComparer.Ordinal);
// Custom/application claims are added first.
// Framework-owned security claims below always take precedence.
- foreach (var kv in context.Claims)
- claims[kv.Key] = kv.Value;
+ foreach (var (type, values) in context.Claims.Claims)
+ {
+ if (values.Count == 0)
+ continue;
+
+ // Framework - owned claims cannot be supplied by applications.
+ if (type is
+ "sub" or
+ "tenant" or
+ "sid" or
+ "jti" or
+ "iss" or
+ "aud" or
+ "exp" or
+ "nbf" or
+ "iat")
+ continue;
+
+ claims[type] = values.Count == 1
+ ? values.First()
+ : values.ToArray();
+ }
// UltimateAuth-owned identity/security claims must never be overridable
// by caller-provided claims.
claims["sub"] = context.UserKey.Value;
- claims["tenant"] = context.Tenant;
+ claims["tenant"] = context.Tenant.Value;
if (context.SessionId is AuthSessionId sessionId)
- claims["sid"] = sessionId;
+ claims["sid"] = sessionId.Value;
+ else
+ claims.Remove("sid");
if (tokens.AddJwtIdClaim)
claims["jti"] = _opaqueGenerator.GenerateJwtId();
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs
index 01d91819..c8a3b6ff 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs
@@ -45,7 +45,7 @@ public AccessDecision Decide(AccessContext context, IEnumerable r
var result = policy.Decide(context);
- if (!result.IsAllowed)
+ if (result.IsDenied)
return result;
if (result.RequiresReauthentication)
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs
index 6fe26b2d..d96af659 100644
--- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs
@@ -30,12 +30,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessCommand command, Ca
var policies = _policyProvider.GetPolicies(context);
var decision = _authority.Decide(context, policies);
+ if (decision.RequiresReauthentication)
+ throw new UAuthChallengeRequiredException(decision.DenyReason ?? "Requires reauthentication.");
+
if (!decision.IsAllowed)
throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied");
- if (decision.RequiresReauthentication)
- throw new InvalidOperationException("Requires reauthentication.");
-
await command.ExecuteAsync(ct);
}
@@ -48,12 +48,12 @@ public async Task ExecuteAsync(AccessContext context, IAccessC
var policies = _policyProvider.GetPolicies(context);
var decision = _authority.Decide(context, policies);
+ if (decision.RequiresReauthentication)
+ throw new UAuthChallengeRequiredException(decision.DenyReason ?? "Requires reauthentication.");
+
if (!decision.IsAllowed)
throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied");
- if (decision.RequiresReauthentication)
- throw new InvalidOperationException("Requires reauthentication.");
-
return await command.ExecuteAsync(ct);
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs
new file mode 100644
index 00000000..8b23a309
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/RefreshTokenMapper.cs
@@ -0,0 +1,14 @@
+using CodeBeam.UltimateAuth.Core.Contracts;
+using CodeBeam.UltimateAuth.Server.Contracts;
+
+internal static class RefreshTokenMapper
+{
+ public static RefreshTokenInfo ToTransport(RefreshTokenIssuanceResult result)
+ {
+ return new RefreshTokenInfo
+ {
+ Token = result.Token,
+ ExpiresAt = result.ExpiresAt
+ };
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs
index 97652d24..7e91f86b 100644
--- a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs
@@ -43,6 +43,11 @@ public sealed class UAuthServerOptions
///
public UAuthMultiTenantOptions MultiTenant { get; set; } = new();
+ ///
+ /// Controls the default and maximum page sizes for paginated queries.
+ ///
+ public UAuthPaginationOptions Pagination { get; set; } = new();
+
// -------------------------------------------------------
// SERVER-ONLY BEHAVIOR
// -------------------------------------------------------
@@ -139,9 +144,10 @@ internal UAuthServerOptions Clone()
Pkce = Pkce.Clone(),
Events = Events.Clone(),
MultiTenant = MultiTenant.Clone(),
+ Pagination = Pagination.Clone(),
+
Cookie = Cookie.Clone(),
Diagnostics = Diagnostics.Clone(),
-
PrimaryCredential = PrimaryCredential.Clone(),
AuthResponse = AuthResponse.Clone(),
ResetCredential = ResetCredential.Clone(),
diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs
new file mode 100644
index 00000000..dfa3079e
--- /dev/null
+++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPaginationOptionsValidator.cs
@@ -0,0 +1,38 @@
+using Microsoft.Extensions.Options;
+
+namespace CodeBeam.UltimateAuth.Server.Options;
+
+internal sealed class UAuthServerPaginationOptionsValidator : IValidateOptions
+{
+ public ValidateOptionsResult Validate(string? name, UAuthServerOptions options)
+ {
+ var errors = new List();
+
+ if (options.Pagination is null)
+ {
+ errors.Add("Pagination configuration cannot be null.");
+ return ValidateOptionsResult.Fail(errors);
+ }
+
+ var pagination = options.Pagination;
+
+ if (pagination.DefaultPageSize <= 0)
+ {
+ errors.Add("Pagination.DefaultPageSize must be greater than zero.");
+ }
+
+ if (pagination.MaxPageSize <= 0)
+ {
+ errors.Add("Pagination.MaxPageSize must be greater than zero.");
+ }
+
+ if (pagination.DefaultPageSize > pagination.MaxPageSize)
+ {
+ errors.Add("Pagination.DefaultPageSize cannot exceed Pagination.MaxPageSize.");
+ }
+
+ return errors.Count == 0
+ ? ValidateOptionsResult.Success
+ : ValidateOptionsResult.Fail(errors);
+ }
+}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs
index 4445c7e1..7745ccb6 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs
@@ -4,6 +4,8 @@
namespace CodeBeam.UltimateAuth.Server.Services;
+// TODO: Implement external login, MFA, and reauthentication flows as commented out in the interface below.
+
///
/// Handles authentication flows such as login,
/// logout, session refresh and reauthentication.
@@ -14,17 +16,17 @@ public interface IUAuthFlowService
Task LoginAsync(AuthFlowContext auth, AuthExecutionContext execution, LoginRequest request, CancellationToken ct);
- Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default);
+ //Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default);
- Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default);
+ //Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default);
- Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default);
+ //Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default);
Task LogoutAsync(LogoutRequest request, CancellationToken ct = default);
Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default);
- Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default);
+ //Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default);
}
internal interface IUAuthInternalFlowService
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs
index ec17b4c9..44b15f1e 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs
@@ -86,13 +86,13 @@ public async Task CompleteAsync(AuthFlowContext auth, PkceCo
var validation = _validator.Validate(artifact, request.CodeVerifier, completionContext, _clock.UtcNow);
- if (!validation.Success)
+ if (!validation.IsSuccess)
{
artifact.RegisterAttempt();
return new PkceCompleteResult
{
- Success = false,
+ IsSuccess = false,
FailureReason = AuthFailureReason.InvalidCredentials
};
}
@@ -114,7 +114,7 @@ public async Task CompleteAsync(AuthFlowContext auth, PkceCo
return new PkceCompleteResult
{
- Success = result.IsSuccess,
+ IsSuccess = result.IsSuccess,
FailureReason = result.FailureReason,
LoginResult = result
};
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs
index 75f77923..fbdc931b 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs
@@ -1,9 +1,10 @@
-using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core;
+using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
-using CodeBeam.UltimateAuth.Server.Abstactions;
+using CodeBeam.UltimateAuth.Server.Abstractions;
using CodeBeam.UltimateAuth.Server.Auth;
namespace CodeBeam.UltimateAuth.Server.Services;
@@ -13,12 +14,15 @@ public sealed class RefreshTokenRotationService : IRefreshTokenRotationService
private readonly IRefreshTokenValidator _validator;
private readonly IRefreshTokenStoreFactory _storeFactory;
private readonly ITokenIssuer _tokenIssuer;
+ private readonly IUserClaimsProvider _claimsProvider;
- public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer)
+
+ public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IUserClaimsProvider claimsProvider)
{
_validator = validator;
_storeFactory = storeFactory;
_tokenIssuer = tokenIssuer;
+ _claimsProvider = claimsProvider;
}
// TODO: Handle reuse detection and make flow knows situation, but don't make security branch.
@@ -71,6 +75,8 @@ public async Task RotateAsync(AuthFlowContext flo
var store = _storeFactory.Create(validation.Tenant);
+ var claims = await _claimsProvider.GetClaimsAsync(validation.Tenant, userKey, ct);
+
var tokenContext = new TokenIssuanceContext
{
Tenant = flow.OriginalOptions.MultiTenant.Enabled
@@ -79,7 +85,8 @@ public async Task RotateAsync(AuthFlowContext flo
UserKey = userKey,
SessionId = sessionId,
- ChainId = validation.ChainId
+ ChainId = validation.ChainId,
+ Claims = claims
};
// Generate candidate replacement refresh token.
@@ -125,6 +132,11 @@ public async Task RotateAsync(AuthFlowContext flo
// Only the winning request needs an access token.
var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct);
+ var refreshTokenInfo = new RefreshTokenInfo
+ {
+ Token = refreshToken.Token,
+ ExpiresAt = refreshToken.ExpiresAt
+ };
return new RefreshTokenRotationExecution
{
@@ -132,8 +144,7 @@ public async Task RotateAsync(AuthFlowContext flo
UserKey = userKey,
SessionId = sessionId,
ChainId = validation.ChainId,
-
- Result = RefreshTokenRotationResult.Success(accessToken, refreshToken)
+ Result = RefreshTokenRotationResult.Success(accessToken, refreshTokenInfo)
};
}
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs
index c993e957..ff3b6e77 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs
@@ -10,12 +10,14 @@ internal sealed class SessionApplicationService : ISessionApplicationService
{
private readonly IAccessOrchestrator _accessOrchestrator;
private readonly ISessionStoreFactory _storeFactory;
+ private readonly IUAuthPaginationPolicy _pagination;
private readonly IClock _clock;
- public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IClock clock)
+ public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IUAuthPaginationPolicy pagination, IClock clock)
{
_accessOrchestrator = accessOrchestrator;
_storeFactory = storeFactory;
+ _pagination = pagination;
_clock = clock;
}
@@ -24,7 +26,7 @@ public async Task> GetUserChainsAsync(AccessCon
var command = new AccessCommand>(async innerCt =>
{
var store = _storeFactory.Create(context.ResourceTenant);
- request = request.Normalize();
+ request = request.Normalize(_pagination);
var chains = await store.GetChainsByUserAsync(userKey);
var actorChainId = context.ActorChainId;
diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
index 85011c70..df1d2e57 100644
--- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
+++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs
@@ -113,23 +113,23 @@ public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct
}
}
- public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default)
- {
- throw new NotImplementedException();
- }
-
- public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default)
- {
- throw new NotImplementedException();
- }
-
- public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default)
- {
- throw new NotImplementedException();
- }
-
- public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default)
- {
- throw new NotImplementedException();
- }
+ //public Task ReauthenticateAsync(ReauthRequest request, CancellationToken ct = default)
+ //{
+ // throw new NotImplementedException();
+ //}
+
+ //public Task BeginMfaAsync(BeginMfaRequest request, CancellationToken ct = default)
+ //{
+ // throw new NotImplementedException();
+ //}
+
+ //public Task CompleteMfaAsync(CompleteMfaRequest request, CancellationToken ct = default)
+ //{
+ // throw new NotImplementedException();
+ //}
+
+ //public Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default)
+ //{
+ // throw new NotImplementedException();
+ //}
}
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs
index 822da088..d8c54b4f 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs
@@ -1,21 +1,31 @@
-namespace CodeBeam.UltimateAuth.Authorization.Contracts;
+using System.Text.Json.Serialization;
-public readonly record struct Permission(string Value)
+namespace CodeBeam.UltimateAuth.Authorization.Contracts;
+
+public readonly record struct Permission
{
- public static Permission From(string value)
+ public string Value { get; }
+
+ [JsonConstructor]
+ public Permission(string value)
{
if (string.IsNullOrWhiteSpace(value))
- throw new ArgumentException("permission_required");
+ throw new ArgumentException("permission_required", nameof(value));
- return new Permission(value.Trim().ToLowerInvariant());
+ Value = value.Trim().ToLowerInvariant();
}
+ public static Permission From(string value) => new(value);
+
public static readonly Permission Wildcard = new("*");
public bool IsWildcard => Value == "*";
- public bool IsPrefix => Value.EndsWith(".*");
- public override string ToString() => Value;
+ public bool IsPrefix => Value?.EndsWith(".*", StringComparison.Ordinal) == true;
+
+ public bool IsValid => !string.IsNullOrWhiteSpace(Value);
+
+ public override string ToString() => Value ?? string.Empty;
public static implicit operator string(Permission p) => p.Value;
}
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs
index 1046ce61..a1403eb3 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs
@@ -3,6 +3,6 @@
public sealed record AuthorizationCheckRequest
{
public required string Action { get; init; }
- public string? Resource { get; init; }
+ public required string Resource { get; init; }
public string? ResourceId { get; init; }
}
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs
deleted file mode 100644
index 4e3f0f33..00000000
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace CodeBeam.UltimateAuth.Authorization.Contracts;
-
-public sealed record AuthorizationRequest
-{
- ///
- /// Logical operation being requested (e.g. "orders.read").
- ///
- public required string Operation { get; init; }
-
- ///
- /// Optional resource identifier (row, entity, aggregate, etc).
- ///
- public string? Resource { get; init; }
-
- ///
- /// Optional resource identifier.
- ///
- public string? ResourceId { get; init; }
-
- ///
- /// Optional contextual attributes for fine-grained access decisions.
- ///
- public IReadOnlyDictionary? Attributes { get; init; }
-}
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs
index 9412811b..3f2e9820 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
@@ -10,11 +11,13 @@ internal sealed class EfCoreRoleStore : IRoleStore where TDbContext
{
private readonly TDbContext _db;
private readonly TenantKey _tenant;
+ private readonly IUAuthPaginationPolicy _pagination;
- public EfCoreRoleStore(TDbContext db, TenantExecutionContext tenant)
+ public EfCoreRoleStore(TDbContext db, TenantExecutionContext tenant, IUAuthPaginationPolicy pagination)
{
_db = db;
_tenant = tenant.Tenant;
+ _pagination = pagination;
}
private DbSet DbSetRole => _db.Set();
@@ -218,7 +221,7 @@ public async Task> GetByIdsAsync(
// TODO: Add UltimateAuth standard: tiebreaker for sorting fields that are not unique.
public async Task> QueryAsync(RoleQuery query, CancellationToken ct = default)
{
- var normalized = query.Normalize();
+ var normalized = query.Normalize(_pagination);
var baseQuery = DbSetRole
.AsNoTracking()
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs
index d6bf6afe..a26c9be1 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs
@@ -1,4 +1,5 @@
-using CodeBeam.UltimateAuth.Core.MultiTenancy;
+using CodeBeam.UltimateAuth.Core.Abstractions;
+using CodeBeam.UltimateAuth.Core.MultiTenancy;
using Microsoft.EntityFrameworkCore;
namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore;
@@ -6,14 +7,16 @@ namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore;
internal sealed class EfCoreRoleStoreFactory : IRoleStoreFactory where TDbContext : DbContext
{
private readonly TDbContext _db;
+ private readonly IUAuthPaginationPolicy _pagination;
- public EfCoreRoleStoreFactory(TDbContext db)
+ public EfCoreRoleStoreFactory(TDbContext db, IUAuthPaginationPolicy pagination)
{
_db = db;
+ _pagination = pagination;
}
public IRoleStore Create(TenantKey tenant)
{
- return new EfCoreRoleStore(_db, new TenantExecutionContext(tenant));
+ return new EfCoreRoleStore(_db, new TenantExecutionContext(tenant), _pagination);
}
}
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs
index 78075797..e7f81625 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Errors;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
@@ -9,9 +10,11 @@ namespace CodeBeam.UltimateAuth.Authorization.InMemory;
internal sealed class InMemoryRoleStore : InMemoryTenantVersionedStore, IRoleStore
{
protected override RoleKey GetKey(Role entity) => new(entity.Tenant, entity.Id);
+ private readonly IUAuthPaginationPolicy _pagination;
- public InMemoryRoleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext) : base(tenant, atomicContext)
+ public InMemoryRoleStore(TenantExecutionContext tenant, InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination) : base(tenant, atomicContext)
{
+ _pagination = pagination;
}
protected override void BeforeAdd(Role entity)
@@ -67,7 +70,7 @@ public Task> QueryAsync(RoleQuery query, CancellationToken ct
{
ct.ThrowIfCancellationRequested();
- var normalized = query.Normalize();
+ var normalized = query.Normalize(_pagination);
var baseQuery = TenantValues().AsQueryable();
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs
index b51d4f2a..346cf043 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.InMemory;
@@ -8,14 +9,16 @@ public sealed class InMemoryRoleStoreFactory : IRoleStoreFactory
{
private readonly ConcurrentDictionary _stores = new();
private readonly InMemoryAtomicContextAccessor _atomicContext;
+ private readonly IUAuthPaginationPolicy _pagination;
- public InMemoryRoleStoreFactory(InMemoryAtomicContextAccessor atomicContext)
+ public InMemoryRoleStoreFactory(InMemoryAtomicContextAccessor atomicContext, IUAuthPaginationPolicy pagination)
{
_atomicContext = atomicContext;
+ _pagination = pagination;
}
public IRoleStore Create(TenantKey tenant)
{
- return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t), _atomicContext));
+ return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantExecutionContext(t), _atomicContext, _pagination));
}
}
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs
index 08ba606f..f84692c5 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs
@@ -1,4 +1,5 @@
using CodeBeam.UltimateAuth.Authorization.Contracts;
+using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Defaults;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Server.Auth;
@@ -39,11 +40,10 @@ public async Task CheckAsync(HttpContext ctx)
var req = await ctx.ReadJsonAsync(ctx.RequestAborted);
- if (string.IsNullOrWhiteSpace(req.Resource))
- return Results.BadRequest("Resource is required for authorization check.");
-
- if (string.IsNullOrWhiteSpace(req.Action))
- return Results.BadRequest("Action is required for authorization check.");
+ if (req is null || string.IsNullOrWhiteSpace(req.Action) || string.IsNullOrWhiteSpace(req.Resource))
+ {
+ return Results.BadRequest("Action and Resource are required.");
+ }
var accessContext = await _accessContextFactory.CreateAsync(
flow,
@@ -119,6 +119,10 @@ public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx)
var req = await ctx.ReadJsonAsync(ctx.RequestAborted);
+ // These are transport rules, not business rules. The business rules are enforced in the service layer.
+ if (req is null || req.UserKey != userKey || string.IsNullOrWhiteSpace(req.RoleName))
+ return Results.BadRequest("Invalid role assignment request.");
+
var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Authorization.Roles.AssignAdmin,
@@ -138,6 +142,9 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx)
var req = await ctx.ReadJsonAsync(ctx.RequestAborted);
+ if (req is null || req.UserKey != userKey || string.IsNullOrWhiteSpace(req.RoleName))
+ return Results.BadRequest("Invalid role removal request.");
+
var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Authorization.Roles.RemoveAdmin,
@@ -178,6 +185,9 @@ public async Task RenameRoleAsync(RoleId roleId, HttpContext ctx)
var req = await ctx.ReadJsonAsync(ctx.RequestAborted);
+ if (req is null || req.Id != roleId || string.IsNullOrWhiteSpace(req.Name))
+ return Results.BadRequest("Invalid role rename request.");
+
var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Authorization.Roles.RenameAdmin,
@@ -199,6 +209,9 @@ public async Task DeleteRoleAsync(RoleId roleId, HttpContext ctx)
var req = await ctx.ReadJsonAsync(ctx.RequestAborted);
+ if (req is null || req.Id != roleId)
+ return Results.BadRequest("Invalid role deletion request.");
+
var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Authorization.Roles.DeleteAdmin,
@@ -219,6 +232,9 @@ public async Task SetRolePermissionsAsync(RoleId roleId, HttpContext ct
var req = await ctx.ReadJsonAsync(ctx.RequestAborted);
+ if (req is null || req.RoleId != roleId || req.Permissions is null)
+ return Results.BadRequest("Invalid role permissions request.");
+
var accessContext = await _accessContextFactory.CreateAsync(
flow,
action: UAuthActions.Authorization.Roles.SetPermissionsAdmin,
@@ -248,6 +264,21 @@ public async Task QueryRolesAsync(HttpContext ctx)
var result = await _roles.QueryAsync(accessContext, req, ctx.RequestAborted);
- return Results.Ok(result);
+ var response = new PagedResult(
+ result.Items.Select(role => new RoleInfo
+ {
+ Id = role.Id,
+ Name = role.Name,
+ Permissions = role.Permissions.ToArray(),
+ CreatedAt = role.CreatedAt,
+ UpdatedAt = role.UpdatedAt
+ }).ToArray(),
+ result.TotalCount,
+ result.PageNumber,
+ result.PageSize,
+ result.SortBy,
+ result.Descending);
+
+ return Results.Ok(response);
}
}
diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs
index 9a3051d1..251eeb77 100644
--- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs
+++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs
@@ -12,13 +12,15 @@ internal sealed class UserRoleService : IUserRoleService
private readonly IAccessOrchestrator _accessOrchestrator;
private readonly IUserRoleStoreFactory _userRoleFactory;
private readonly IRoleStoreFactory _roleFactory;
+ private readonly IUAuthPaginationPolicy _pagination;
private readonly IClock _clock;
- public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IClock clock)
+ public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IUAuthPaginationPolicy pagination, IClock clock)
{
_accessOrchestrator = accessOrchestrator;
_userRoleFactory = userRoleFactory;
_roleFactory = roleFactory;
+ _pagination = pagination;
_clock = clock;
}
@@ -26,6 +28,8 @@ public async Task AssignAsync(AccessContext context, UserKey targetUserKey, stri
{
ct.ThrowIfCancellationRequested();
+ ValidateTargetUser(context, targetUserKey);
+
var now = _clock.UtcNow;
var cmd = new AccessCommand(async innerCt =>
@@ -48,6 +52,8 @@ public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, stri
{
ct.ThrowIfCancellationRequested();
+ ValidateTargetUser(context, targetUserKey);
+
var cmd = new AccessCommand(async innerCt =>
{
var roleStore = _roleFactory.Create(context.ResourceTenant);
@@ -68,9 +74,11 @@ public async Task> GetRolesAsync(AccessContext context
{
ct.ThrowIfCancellationRequested();
+ ValidateTargetUser(context, targetUserKey, allowSelf: true);
+
var cmd = new AccessCommand>(async innerCt =>
{
- request = request.Normalize();
+ request = request.Normalize(_pagination);
var roleStore = _roleFactory.Create(context.ResourceTenant);
var userRoleStore = _userRoleFactory.Create(context.ResourceTenant);
@@ -132,4 +140,17 @@ public async Task> GetRolesAsync(AccessContext context
return await _accessOrchestrator.ExecuteAsync(context, cmd, ct);
}
+
+ private static void ValidateTargetUser(AccessContext context, UserKey targetUserKey, bool allowSelf = false)
+ {
+ if (context.TargetUserKey == targetUserKey)
+ return;
+
+ if (allowSelf &&
+ context.TargetUserKey is null &&
+ context.ActorUserKey == targetUserKey)
+ return;
+
+ throw new UAuthAuthorizationException("target_user_mismatch");
+ }
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs
index 38c7fe96..510acac1 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs
@@ -83,9 +83,6 @@ public async Task SendJsonAsync(string endpoint, object? p
if (result.Status == 0)
throw new UAuthTransportException("Network error.");
- //if (result.Status >= 500)
- // throw new UAuthTransportException($"Server error {result.Status}", (HttpStatusCode)result.Status);
-
return result;
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js
index a1eddceb..01565046 100644
--- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js
+++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js
@@ -57,33 +57,59 @@ window.uauth.submitForm = function (form) {
window.uauth.tryAndCommit = async function (options) {
const { tryUrl, commitUrl, data, clientProfile } = options;
+ const deviceId = window.uauth.deviceId;
+
+ if (!deviceId) {
+ throw new Error("UAuth deviceId is not initialized.");
+ }
+
const tryResponse = await window.uauth.postJson({
url: tryUrl,
payload: data,
- clientProfile: clientProfile
+ clientProfile
});
- let result = tryResponse?.body;
+ if (!tryResponse || !Number.isInteger(tryResponse.status)) {
+ throw new Error("Invalid UAuth transport response.");
+ }
+
+ const status = tryResponse.status;
+ const isHttpSuccess = status >= 200 && status < 300;
+ const isClientError = status >= 400 && status < 500;
+
+ if (!isHttpSuccess && !isClientError) {
+ throw new Error(`Unexpected UAuth try response: HTTP ${status}.`);
+ }
+
+ const result = tryResponse.body;
+
+ if (!result ||
+ typeof result !== "object" ||
+ Array.isArray(result) ||
+ typeof result.isSuccess !== "boolean") {
+ throw new Error("Invalid UAuth try response body.");
+ }
- if (!result) {
- result = {};
+ if (isClientError && result.isSuccess) {
+ throw new Error("Successful authentication result returned with HTTP error.");
}
const normalized = {
- success: result.success ?? false,
+ isSuccess: result.isSuccess,
reason: result.reason ?? null,
remainingAttempts: result.remainingAttempts ?? null,
lockoutUntilUtc: result.lockoutUntilUtc ?? null,
requiresMfa: result.requiresMfa ?? false,
- retryWithNewPkce: result.retryWithNewPkce ?? false
+ retryWithNewPkce: result.retryWithNewPkce ?? false,
+ previewReceipt: result.previewReceipt ?? null
};
- if (normalized.success) {
+ if (normalized.isSuccess) {
const form = document.createElement("form");
form.method = "POST";
form.action = commitUrl;
- for (const key in data) {
+ for (const key of Object.keys(data)) {
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
@@ -91,6 +117,14 @@ window.uauth.tryAndCommit = async function (options) {
form.appendChild(input);
}
+ if (normalized.previewReceipt) {
+ const receipt = document.createElement("input");
+ receipt.type = "hidden";
+ receipt.name = "PreviewReceipt";
+ receipt.value = normalized.previewReceipt;
+ form.appendChild(receipt);
+ }
+
const cp = document.createElement("input");
cp.type = "hidden";
cp.name = "__uauth_client_profile";
@@ -100,7 +134,7 @@ window.uauth.tryAndCommit = async function (options) {
const udid = document.createElement("input");
udid.type = "hidden";
udid.name = "__uauth_device";
- udid.value = window.uauth.deviceId;
+ udid.value = deviceId;
form.appendChild(udid);
document.body.appendChild(form);
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 61a3dfaf..197576de 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:f,commitUrl:e,data:i,clientProfile:r}=n,o=await window.uauth.postJson({url:f,payload:i,clientProfile:r});let t=o?.body;t||(t={});const u={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1};if(u.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t]??"";n.appendChild(r)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=r??"";n.appendChild(t);const u=document.createElement("input");u.type="hidden";u.name="__uauth_device";u.value=window.uauth.deviceId;n.appendChild(u);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),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}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};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:h,commitUrl:c,data:f,clientProfile:e}=n,o=window.uauth.deviceId;if(!o)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:h,payload:f,clientProfile:e});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status,l=i>=200&&i<300,s=i>=400&&i<500;if(!l&&!s)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(s&&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=c;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=e??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=o;n.appendChild(i);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),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}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};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/Extensions/LoginRequestFormExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs
index 8e9defcb..a32446b1 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs
@@ -5,9 +5,16 @@ namespace CodeBeam.UltimateAuth.Client.Extensions;
internal static class LoginRequestFormExtensions
{
public static IDictionary ToDictionary(this LoginRequest request)
- => new Dictionary
+ {
+ var payload = new Dictionary
{
["Identifier"] = request.Identifier,
["Secret"] = request.Secret
};
+
+ if (!string.IsNullOrWhiteSpace(request.PreviewReceipt))
+ payload["PreviewReceipt"] = request.PreviewReceipt;
+
+ return payload;
+ }
}
diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs
index 2156fafa..da59de93 100644
--- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs
+++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs
@@ -8,39 +8,39 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure;
internal static class UAuthResultMapper
{
- private static readonly JsonSerializerOptions _jsonOptions =
- new() { PropertyNameCaseInsensitive = true };
+ private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
public static UAuthResult FromJson(UAuthTransportResult raw)
{
EnsureTransport(raw);
- if (raw.Status >= 400 && raw.Status < 500)
+ if (!IsSuccessStatus(raw.Status))
{
- var problem = TryDeserializeProblem(raw);
+ var problem = CreateProblem(raw);
return new UAuthResult
{
IsSuccess = false,
Status = raw.Status,
- Problem = problem
+ Problem = problem,
+ TraceId = problem?.TraceId
};
}
- if (raw.Body is null)
+ if (raw.Body is null || raw.Body.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
- return new UAuthResult
- {
- IsSuccess = true,
- Status = raw.Status,
- Value = default
- };
+ throw new UAuthProtocolException($"Expected response body for {typeof(T).Name}, but received none.");
}
try
{
var value = raw.Body.Value.Deserialize(_jsonOptions);
+ if (value is null)
+ {
+ throw new UAuthProtocolException($"Response body for {typeof(T).Name} was null.");
+ }
+
return new UAuthResult
{
IsSuccess = true,
@@ -54,27 +54,84 @@ public static UAuthResult FromJson(UAuthTransportResult raw)
}
}
- public static UAuthResult From(UAuthTransportResult raw) => FromJson